防止错误响应回归的契约测试:设计原理
一句话总结
测试已知错误、未知错误、非公开信息和追踪请求头。
为什么需要它
错误消息被修改之后,移动端 App 的重试逻辑坏掉了。测试只确认了“抛出了异常”这一事实,并没有比较状态和公开正文的含义。内部异常原样暴露给用户的回归,也从同一个缺口溜了过去。
工作原理
把正常的 code 和 unknown 做成表格,比较 status 和公开文案。请求 id 要在长度边界的两侧都检查。确认问题正文的键集合和 Content-Type,并在真实的异常路径中观察请求头与正文是否带有相同的追踪值。
학생 테스트 → 정상 구현: 실제 시험 모두 통과
└→ 계약 위반 구현: 해당 동작에서 실패
수집 실패·0개 실행·강제 종료 ≠ 결함 검출
阅读契约并预测失败的工作表
下面不是让你背下整个实现的答案,而是逐步骤的代码评审。每个改动片段都会故意破坏契约。请注意,改动之后正常用例仍然可能通过。运行之前先预测:观察哪些输入、异常和状态,差异才会暴露出来;实现之后,再把预测与结果进行比较。
1. 在业务异常中保留 code——测试
测试所提供 service.py 的下列公开契约:DomainError(code, message) 是 Exception 的子类,在 .code 中保存 code。str(异常) 就是 message。在正确实现上应当通过,而在违反该契约的实现上,必须由测试本体的实际失败将其检出。保留前面步骤的测试,并添加 test_ 函数。
判断依据:把供机器判断的 code 与供人查看的内部消息分开。不要修改实现文件。用 pytest.raises 确认预期的异常,对正常结果则断言具体的预期值。
待评审的错误改动片段:
self.code = "invalid"
把它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观察对象。
2. 把 code 映射为状态——测试
测试所提供 service.py 的下列公开契约:status_for(code) 对 missing 返回 404,对 conflict 返回 409,对 invalid 返回 422,其他返回 500。在正确实现上应当通过,而在违反该契约的实现上,必须由测试本体的实际失败将其检出。保留前面步骤的测试,并添加 test_ 函数。
判断依据:不要把未知的 code 当作成功处理。不要修改实现文件。用 pytest.raises 确认预期的异常,对正常结果则断言具体的预期值。
待评审的错误改动片段:
.get(code, 200)
把它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观察对象。
3. 固定公开文案——测试
测试所提供 service.py 的下列公开契约:public_message(code) 对 missing 返回 'Resource not found',对 conflict 返回 'State conflict',对 invalid 返回 'Invalid request',其他返回 'Internal error'。在正确实现上应当通过,而在违反该契约的实现上,必须由测试本体的实际失败将其检出。保留前面步骤的测试,并添加 test_ 函数。
判断依据:即使异常字符串中含有数据库地址或内部路径,也不对外公开。不要修改实现文件。用 pytest.raises 确认预期的异常,对正常结果则断言具体的预期值。
待评审的错误改动片段:
"invalid":"Internal error"
把它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观察对象。
4. 限制请求 id——测试
测试所提供 service.py 的下列公开契约:request_id(value) 在 value 是只由 ASCII 字母、数字、下划线和连字符组成、长度为 1–32 的字符串时原样返回,否则返回 'untracked'。在正确实现上应当通过,而在违反该契约的实现上,必须由测试本体的实际失败将其检出。保留前面步骤的测试,并添加 test_ 函数。
判断依据:同时限制长度和字符集,避免原样回显任意请求头。不要修改实现文件。用 pytest.raises 确认预期的异常,对正常结果则断言具体的预期值。
待评审的错误改动片段:
{1,64}
把它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观察对象。
5. 构造问题正文——测试
测试所提供 service.py 的下列公开契约:problem(code, rid) 返回只含下列键的字典:type='urn:labhub:problem:'+code,title 和 detail=public_message(code),status=status_for(code),request_id=request_id(rid)。在正确实现上应当通过,而在违反该契约的实现上,必须由测试本体的实际失败将其检出。保留前面步骤的测试,并添加 test_ 函数。
判断依据:如果正文和 HTTP 状态互相不一致,客户端就无法决定该相信哪个值。不要修改实现文件。用 pytest.raises 确认预期的异常,对正常结果则断言具体的预期值。
待评审的错误改动片段:
"status":500
把它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观察对象。
6. 让响应格式保持一致——测试
测试所提供 service.py 的下列公开契约:response_for(code, rid) 返回一个 JSONResponse:以 problem 为正文,以 status_for 为状态,以 application/problem+json 为 media_type,以规范化后的 rid 作为 X-Request-ID。在正确实现上应当通过,而在违反该契约的实现上,必须由测试本体的实际失败将其检出。保留前面步骤的测试,并添加 test_ 函数。
判断依据:直接返回简单字典,可能会让错误也变成 200。不要修改实现文件。用 pytest.raises 确认预期的异常,对正常结果则断言具体的预期值。
待评审的错误改动片段:
media_type="application/json"
把它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观察对象。
7. 接入业务异常处理器——测试
测试所提供 service.py 的下列公开契约:install_handlers(app) 会注册 DomainError 的处理器。它读取请求头 X-Request-ID,并针对 exc.code 返回 response_for。exc 的 message 不会放进响应。在正确实现上应当通过,而在违反该契约的实现上,必须由测试本体的实际失败将其检出。保留前面步骤的测试,并添加 test_ 函数。
判断依据:不要把捕获异常的位置分散开,而要放在应用的公共边界上。不要修改实现文件。用 pytest.raises 确认预期的异常,对正常结果则断言具体的预期值。
待评审的错误改动片段:
response_for("invalid", request.headers.get("x-request-id"))
把它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观察对象。
8. 连意料之外的错误也隐藏起来——测试
测试所提供 service.py 的下列公开契约:create_app() 会安装处理器,并在 GET /fail/{code} 上抛出 DomainError(code, 内部消息)。但当 code=boom 时,抛出的是 RuntimeError。RuntimeError 的处理器返回 code=internal 的固定 500 问题响应。在正确实现上应当通过,而在违反该契约的实现上,必须由测试本体的实际失败将其检出。保留前面步骤的测试,并添加 test_ 函数。
判断依据:在测试中关闭异常的重新抛出,检查真实 500 响应的字节。不要修改实现文件。用 pytest.raises 确认预期的异常,对正常结果则断言具体的预期值。
待评审的错误改动片段:
response_for("invalid", request.headers.get("x-request-id"))
把它与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观察对象。
在现场相遇的样子
本实验的问题响应是带有 type、title、status、detail、request_id 的教学用契约。不主张通用的国际化,也不主张完全符合标准。request id 是用于追踪的字符串,而不是认证手段;生产日志中也不应原样记录机密值。提供的实现可以阅读,但评分使用的是另外的副本。不要通过检查源码措辞或修改文件来绕过缺陷,而要检查公开接口的实际运行结果。
下一项实验要做什么
八个步骤会连成一个可运行的成果。在业务异常中保留 code——测试 → 把 code 映射为状态——测试 → 固定公开文案——测试 → 限制请求 id——测试 → 构造问题正文——测试 → 让响应格式保持一致——测试 → 接入业务异常处理器——测试 → 连意料之外的错误也隐藏起来——测试。
每一步检查的都不是函数或文件是否存在,而是实际的返回值、异常和状态变化。看过正确答案之后,请故意改动边界比较或清理代码,确认哪些测试会失败。说明为什么前面的测试在后面的步骤中仍然保留,并写出一种本实验不能保证的生产条件。