← [ BACK_TO_NOTES ]

Python 用 pytest 做测试

pytest 用断言即测试、fixture 与参数化组织可维护测试套件。

#concept #growing #dev #python #python

[!info] related notes

Python 用 pytest 做测试

这个概念为什么出现

没有自动化测试,重构与依赖升级都在赌。pytest 以低样板成为 Python 事实标准:普通 assert、丰富插件、fixture 注入。

[!abstract] 一句话理解 pytest 收集 test_*,用 assert 验证行为;fixture 管理依赖,参数化覆盖多例。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:为折扣函数写参数化测试

# test_discount_pytest_style.py
# 业务意图:验证折扣计算(可直接 pytest 本文件)。
# 教学点:
# - assert;
# - pytest.mark.parametrize;
# - 异常断言。

from decimal import Decimal, ROUND_HALF_UP

import pytest


def price_after_discount(price_cents: int, discount: Decimal) -> int:
    if not discount.is_finite() or not 0 <= discount <= 1:
        raise ValueError("bad discount")
    amount = Decimal(price_cents) * (1 - discount)
    return int(amount.quantize(Decimal("1"), rounding=ROUND_HALF_UP))


@pytest.mark.parametrize(
    "price,discount,expected",
    [
        (1000, Decimal("0.1"), 900),
        (1000, Decimal("0"), 1000),
        (100, Decimal("0.29"), 71),
        (101, Decimal("0.5"), 51),
    ],
)
def test_price_after_discount(price, discount, expected):
    assert price_after_discount(price, discount) == expected


def test_bad_discount():
    with pytest.raises(ValueError):
        price_after_discount(1000, Decimal("2"))

建议运行:

pytest -q test_discount_pytest_style.py

期望输出:

...
5 passed

(若环境未装 pytest:pip install pytest 后重跑。)

示例约定输入是非负整分金额和由十进制字符串构造的折扣,按 ROUND_HALF_UP 舍入到分;这是一种业务规则,不是所有支付场景的默认规则。float + int 截断写法可能在边界少算一分。

结合场景再看三个关注点

  1. 表驱动减少重复测试函数。
  2. raises 锁定失败契约。
  3. 业务函数与测试可同逻辑演进。

Fixture、Fake 与 monkeypatch 怎么配合

这三个词经常一起出现,但职责不同。

[!abstract] 先理解设计,再记 API

  • fixture:准备测试需要的对象或环境
  • Fake:用一个简化实现替代昂贵、不稳定或不想一起测试的真实依赖
  • monkeypatch:在测试期间临时把真实对象替换成 Fake / Stub

例如只想测试 FastAPI route,而不想真的运行 DiagnosisService 和 LLM:

测试目标
  ↓
FastAPI Route
  ↓
Fake DiagnosisService

真实 DiagnosisService / LLM 不进入这条测试路径

若 route 在调用时从该模块全局名称查找 get_diagnosis_service,可这样替换(项目集成片段,需已有 client fixture 和对应路由):

class FakeDiagnosisService:
    async def generate_diagnosis(self, **kwargs):
        return {"diagnoses": []}


def test_route(client, monkeypatch):
    fake = FakeDiagnosisService()

    monkeypatch.setattr(
        "src.api.routes.diagnosis.get_diagnosis_service",
        lambda: fake,
    )

    response = client.post(
        "/api/diagnosis/analyze",
        json={"extracted_info": [], "profile": {}},
    )

    assert response.status_code == 200

若路由用 Depends(get_diagnosis_service),FastAPI 在注册路由时已保留依赖 callable;上述模块属性替换通常不会改变它。应以原 callable 为键覆盖依赖,例如在测试中用 monkeypatch.setitem(client.app.dependency_overrides, get_diagnosis_service, lambda: fake),测试结束后由 monkeypatch 恢复该条目。

200 状态码只证明请求成功返回,还应按路由契约检查响应体,必要时检查 Fake 收到的输入。本片段不验证真实 DiagnosisService 或 LLM。

这里真正重要的不是 monkeypatch.setattr 语法,而是测试边界:

我只验证 Route 的 HTTP 行为
        ↓
所以把更深层的真实依赖隔离掉

monkeypatch 是工具,Fake 是测试替身;它们都不是 Unit / Integration / E2E 之外的新测试层级。完整模型见 测试层级与测试边界。

核心概念与准确模型

  • 默认发现 test_.py 或 _test.py 文件中的 test 函数,以及无 init 的 Test 类中的 test* 方法;可由配置调整。
  • fixture 作用域:function/class/module/package/session;yield 后执行清理,宽作用域 fixture 不能依赖更窄作用域 fixture。
  • conftest.py 共享
  • 插件:cov、xdist、mock

边界情况与反直觉行为

  1. 断言重写让失败信息更详,但奇妙魔法需了解。
  2. 测试间污染全局状态。
  3. 异步测试需插件/模式。

常见误区

[!warning] 常见误区:只测成功路径 错误理解:happy path 足够。
正确模型:边界与异常同样是契约。

工程实践

  • Arrange-Act-Assert
  • 命名表达场景
  • CI 默认 pytest

本节总结

pytest 让测试成为日常反馈环。先测行为,再谈覆盖率数字。

自测题

  1. fixture 解决什么?
  2. 参数化适合什么?
  3. Fake 和 monkeypatch 的职责有什么不同?
参考答案
  1. 可复用的测试依赖准备/清理。
  2. 同一逻辑多组输入输出。
  3. Fake 是替代真实依赖的简化实现;monkeypatch 是测试期间完成临时替换的工具。

延伸阅读与资料来源

资料类型支撑内容
pytest docs文档官方
FastAPI testing dependencies文档Depends 的覆盖机制
创建于 2026/7/15 更新于 2026/10/17
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本