一个错误测试,怎样错误地通过¶
下面的测试看上去覆盖了业务错误:
def test_bad_request(client):
response = client.post("/plans", json={"topic": "pytest", "weekly_hours": 0})
assert response.status_code >= 400
如果路由根本不存在,它会得到 404 并通过;如果认证中间件误拦截得到 401,也会 通过。测试确实看到了“错误”,却没有证明约定的错误。错误路径测试的关键不是让 程序失败,而是让它以预期的分类和公开语义失败。
运行环境¶
本章示例已在 Python 3.12.3、pytest 9.1.1 与 FastAPI 0.115.14 验证。代码围绕
同一 /plans 契约;具体错误文本可能随依赖小版本变化,断言聚焦稳定的状态码、
字段位置和业务错误代码。
1. 一个测试只讲一个行为故事¶
概念|行为故事结构 用 arrange 建立前置条件、act 触发目标行为、assert 对 oracle 作判断;三部分共同 服务一个清晰的行为目的,必要时再补 cleanup。
先把测试分成四段:
def test_create_plan_returns_public_plan(client):
# arrange
payload = {"topic": "pytest", "weekly_hours": 8}
# act
response = client.post("/plans", json=payload)
# assert
assert response.status_code == 201
assert response.json()["topic"] == "pytest"
assert response.json()["weekly_hours"] == 8
arrange 建立明确前置条件,act 只触发目标行为,assert 对 oracle 作判断;有资源时 还需要 cleanup。多个断言可以共存,只要它们共同证明一个行为目的。
测试名也应表达结果,而非含糊的 test_plan。当它失败时,你希望第一眼看到的是
“公开计划响应不成立”,而不是先阅读二十行准备代码猜它在测什么。
pytest 使用普通 assert,并在收集到的测试模块中重写断言,从而显示表达式差异。
因此直接比较结构通常比 assert response.json() 更有诊断价值。
2. 成功路径:足够证明,但不把内部实现焊死¶
成功路径至少要证明与当前风险相关的公开结果:
body = response.json()
assert response.status_code == 201
assert body == {
"topic": "pytest",
"weekly_hours": 8,
}
如果响应还有动态 id,可以断言其类型或格式,而不是抄死某个值。不要断言
_normalize_topic() 被调用一次,除非该交互本身就是契约;否则一次内部重构会让
测试失败,用户行为却没有变化。
“最低充分”并不是断言越少越好,而是每个断言都能说明它排除的目标缺陷。只写
assert body 太弱;逐个断言所有日志和私有字段又太强。
3. 校验失败与业务失败是两份契约¶
输入校验失败发生在请求无法形成合法业务输入时:
def test_create_plan_rejects_non_positive_hours(client):
response = client.post(
"/plans", json={"topic": "pytest", "weekly_hours": 0}
)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"][-1] == "weekly_hours"
业务失败则是输入本身合法,但业务前提不成立:
def test_create_plan_reports_topic_conflict(client):
payload = {"topic": "pytest", "weekly_hours": 8}
assert client.post("/plans", json=payload).status_code == 201
response = client.post("/plans", json=payload)
assert response.status_code == 409
assert response.json()["error"]["code"] == "plan_conflict"
二者不能合并为“4xx 测试”。422 告诉客户端修正输入形状或取值;409 告诉客户端
请求合法,但与当前资源状态冲突。若实现意外抛出 KeyError,宽泛的
pytest.raises(Exception) 也会通过,从而把程序 bug 固定成“预期行为”。
错误文本常含版本相关措辞,断言时优先选择契约明确的稳定字段。不要因为避免脆弱 就只断言状态码;正确做法是选择稳定且足够的公开语义。
4. 同一份契约可以复用案例结构¶
概念|pytest 参数化 使用
@pytest.mark.parametrize让共享同一契约的多组输入分别形成独立测试实例, 复用结构但保留每组失败的独立身份。
多个非法时长共享相同的 arrange、act 和 oracle,可以参数化:
import pytest
@pytest.mark.parametrize(
"weekly_hours",
[
pytest.param(0, id="zero"),
pytest.param(-1, id="negative"),
pytest.param(41, id="above-limit"),
],
)
def test_create_plan_rejects_invalid_hours(client, weekly_hours):
response = client.post(
"/plans",
json={"topic": "pytest", "weekly_hours": weekly_hours},
)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"][-1] == "weekly_hours"
每组参数是独立测试实例,id 让失败输出直接指出 zero、negative 或
above-limit。但不要把 201 成功、422 校验和 409 冲突塞进同一张表:它们的
前置条件、行为意义与 oracle 不同,强行合并只会制造条件分支。
常见误区¶
status_code >= 400:吞掉错误分类。raises(Exception):无关 bug 也会通过。assert response.json():只能证明 JSON 非空。- 一个巨大参数表:测试正文充满
if expected_status == ...,说明契约并不相同。 - 断言完整错误消息:把依赖小版本的措辞误当成业务契约。
本章小结¶
聚焦的测试用 arrange-act-assert 讲清一个行为故事。成功路径断言最低充分的公开 结果;校验失败与业务失败分别证明具体分类与稳定结构;参数化只组织共享同一契约 的案例维度。目标不是让代码“报过错”,而是让它以正确方式成功或失败。
练习¶
修正下面的测试设计:
@pytest.mark.parametrize(
"payload,expected",
[
({"topic": "pytest", "weekly_hours": 8}, 201),
({"topic": "", "weekly_hours": 8}, 422),
({"topic": "pytest", "weekly_hours": 8}, 409),
],
)
def test_plan(client, payload, expected):
response = client.post("/plans", json=payload)
assert response.status_code == expected
指出至少三个问题,并给出拆分方向。
练习解析¶
三个案例不共享同一契约:成功、校验失败、冲突的前置条件和 oracle 都不同;两个 相同 payload 期望 201/409,却没有显式建立“topic 已存在”的状态;每例只断言状态码, 没有验证成功结构、校验字段或业务错误代码。
应拆成:正常创建测试;空 topic 校验测试(可与其他同类非法 topic 参数化);先 创建再重复请求的冲突测试。每个测试补上对应的稳定响应断言,冲突测试还应确认 已有状态未被破坏。