跳转至

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 尚不存在   POST       状态码、响应 JSON、重要状态

如果缺少任一项,测试目标都会模糊。例如“测试创建计划”没有写清 topic 是否已 存在;“应该成功”也没有说明成功是返回非空对象、HTTP 201,还是状态被正确保存。

暂停预测:下面这条检查能证明什么?

response = client.post("/plans", json=payload)
assert response.status_code != 500

它只能排除一个状态码。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. 它是否直接影响主要用户结果?
  2. 错误后果是否严重或难以发现?
  3. 边界和状态组合是否复杂?
  4. 它是否经常变化,容易发生回归?

例如,格式化一个内部日志字符串通常低于以下三项:

  • 时长必须为 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
  1. 这条测试实际证明了什么?
  2. 写出三条语义完整的测试目标,每条包含前置条件、刺激和 oracle。
  3. 哪条目标最需要同时观察“失败后的状态”?为什么?

练习解析

  1. 它只证明该输入获得 2xx/3xx 范围内被 HTTPX 视为成功的响应;没有精确证明 201、响应结构、校验失败、冲突分类或状态不变量。
  2. 可写为:
  3. topic 尚不存在时发送合法请求,响应为 201,topic 与时长符合契约;
  4. 发送空 topic,请求被 422 拒绝,错误位置指向 topic,不创建计划;
  5. 同一用户已有同 topic 计划时再次创建,响应为 409、错误代码明确,原计划不变。
  6. 冲突路径尤其需要观察原计划不变,因为只断言 409 无法排除“先覆盖、再报冲突” 的部分副作用。校验失败同样可检查未创建,但冲突场景已有重要状态,更容易被破坏。

参考资料