跳转至

一个错误测试,怎样错误地通过

下面的测试看上去覆盖了业务错误:

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 让失败输出直接指出 zeronegativeabove-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 参数化);先 创建再重复请求的冲突测试。每个测试补上对应的稳定响应断言,冲突测试还应确认 已有状态未被破坏。

参考资料