201 Created,也可能什么都没证明¶
你刚为 AI 学习助手完成 POST /plans。手工发送一份正常请求,服务返回 201,
JSON 看起来也像一份计划。演示很顺利,直到同事换了两个输入:
weekly_hours=0仍然创建成功;- 重复创建同一 topic 时,服务返回 500,而契约约定的是 409。
第一次请求不是“假的”,但它只证明了一个输入走过了一条路径。它没有证明边界 规则,也没有证明错误分类。自动化测试的起点因此不是 pytest 语法,而是回答: 我们究竟要证明哪条承诺,观察什么才算证明成立?
材料说明¶
本章沿用 W01-L02 的 HTTP 契约和 W01-L03 的 service operation,不要求主项目 存在。以下示例契约仅作为贯穿材料:
| 场景 | 刺激 | 公开结果 |
|---|---|---|
| 正常创建 | topic 非空,weekly_hours 为 1~40 |
201,返回计划 |
| 输入校验失败 | weekly_hours=0 |
422,结构化校验错误 |
| 业务冲突 | topic 已存在 | 409,error.code=plan_conflict |
1. 先把“跑过”与“正确”分开¶
一次成功请求观察到的是:在某组前置条件和输入下,系统产生了某个响应。它没有 自动覆盖相邻输入,更没有验证失败后果。
把一次请求拆开,会看到四个位置:
如果缺少任一项,测试目标都会模糊。例如“测试创建计划”没有写清 topic 是否已 存在;“应该成功”也没有说明成功是返回非空对象、HTTP 201,还是状态被正确保存。
暂停预测:下面这条检查能证明什么?
它只能排除一个状态码。404、409、422 都会通过,甚至错误结构完全不符合契约也 会通过。这个断言没有清楚的“正确答案”。
2. 给正确答案找一个可观察依据¶
概念|test oracle 在给定条件下,用来判定实际结果是否符合预期的可观察依据。它的答案来自业务 契约和重要不变量,不能从当前实现照抄。
这个词听起来像神谕,实际更朴素:什么可观察事实足以判定契约成立?
oracle 常来自:
- 返回值或公开响应;
- 重要状态变化;
- 明确的公开异常;
- 交互本身属于契约时的必要交互。
对贯穿案例,三条 oracle 可以写成:
正常创建:状态码是 201;响应包含同一 topic 和合法时长。
校验失败:状态码是 422;错误位置指向 weekly_hours;计划未被创建。
业务冲突:状态码是 409;错误代码是 plan_conflict;既有计划未被覆盖。
注意最后两条不仅是“发生错误”,还说明是哪种错误,以及失败后不应出现什么
状态。raises(Exception) 或 status_code >= 400 太宽,会让无关 bug 冒充预期
失败。
oracle 也不能直接抄当前实现的输出。如果实现错误地返回 500,你把 500 写进 断言,测试只会忠实地替 bug 站岗。权威来源应是业务契约、HTTP 契约和重要不变量。
3. 核心路径不是所有函数的合影¶
概念|风险驱动测试目标 根据用户结果、错误后果、边界复杂度和变化概率选择优先验证的行为,而不是按 函数数量平均分配测试。
项目里可以有几十个函数,但并非每个函数都值得独立测试。选择优先级时可以问:
- 它是否直接影响主要用户结果?
- 错误后果是否严重或难以发现?
- 边界和状态组合是否复杂?
- 它是否经常变化,容易发生回归?
例如,格式化一个内部日志字符串通常低于以下三项:
- 时长必须为 1~40 的核心规则;
- 路由能否把校验失败变成 422;
- 业务冲突能否稳定变成 409。
这并不意味着日志永远不测试,而是测试预算首先服务于关键行为和风险。代码行数、 函数数或固定 coverage 百分比都不能替你做这个判断。
4. 把契约变成可交接的验证清单¶
下面会使用“单元”和“集成”给验证位置做初步分类。这里先只需要两句够读表格的 说明:
- 单元:直接调用 core 行为、把外部依赖控制在自己手里的验证位置;
- 集成:让真实 FastAPI 组件(路由、请求绑定、handler、响应序列化)共同 参与的验证位置。
边界具体划在哪里、凭什么选择层级、同一路径为什么有时需要两层,都由下一章 正式回答。
现在可以形成后续设计的输入:
| 行为 | 前置条件 | 刺激 | oracle | 主要风险 | 建议位置 |
|---|---|---|---|---|---|
| 生成计划 | topic 不存在 | 合法请求 | 计划规则成立 | core 规则错误 | 单元 |
| 暴露成功响应 | 同上 | POST | 201 + 公开 JSON | 路由/序列化偏差 | 集成 |
| 拒绝非法时长 | 无 | weekly_hours=0 |
422 + 字段位置 | 校验边界漂移 | 集成 |
| 拒绝重复 topic | topic 已存在 | POST | 409 + 错误代码 | 错误被误报 500 | 单元 + 集成 |
因此“建议位置”此时只是初步判断,够用来回答“这条 oracle 需要谁在场”。
清单的价值是让每项测试都能追溯到公开行为或重要状态,而不是从源文件列表机械 生成测试。
常见误区¶
误区一:每个函数一个测试,就不会漏。 这会漏掉跨组件契约,也会把私有拆分 固化成测试目标。函数重构后行为没变,测试却大量失败,信号反而变差。
误区二:成功路径最重要,所以先只测成功。 核心路径包含会直接影响用户结果 的失败。校验失败和业务冲突不是边角料,它们是 API 的公开承诺。
误区三:当前输出就是预期输出。 这样会形成自证循环。预期必须回到契约或 重要不变量,而不是从待验证实现抄答案。
本章小结¶
测试从可观察承诺开始:写清前置条件、刺激和 oracle,再按用户结果与错误风险 决定优先级。一次成功演示、函数列表和覆盖数字都不能代替这一步。形成验证清单后, 后续才能有依据地选择测试层级与断言位置。
练习¶
某 endpoint POST /plans 的契约规定:合法请求返回 201;topic 为空返回 422;
同一用户重复 topic 返回 409 且原计划不变。当前测试只有:
def test_create(client):
response = client.post("/plans", json={"topic": "pytest", "weekly_hours": 8})
assert response.ok
- 这条测试实际证明了什么?
- 写出三条语义完整的测试目标,每条包含前置条件、刺激和 oracle。
- 哪条目标最需要同时观察“失败后的状态”?为什么?
练习解析¶
- 它只证明该输入获得 2xx/3xx 范围内被 HTTPX 视为成功的响应;没有精确证明 201、响应结构、校验失败、冲突分类或状态不变量。
- 可写为:
- topic 尚不存在时发送合法请求,响应为 201,topic 与时长符合契约;
- 发送空 topic,请求被 422 拒绝,错误位置指向 topic,不创建计划;
- 同一用户已有同 topic 计划时再次创建,响应为 409、错误代码明确,原计划不变。
- 冲突路径尤其需要观察原计划不变,因为只断言 409 无法排除“先覆盖、再报冲突” 的部分副作用。校验失败同样可检查未创建,但冲突场景已有重要状态,更容易被破坏。