契约测试计划:从 OpenAPI 到 CI
我的原则是:如果一份 OpenAPI 规格没有作为可执行契约接入 CI,那它就只是装饰品。没有任何测试去对它做断言的规格,两个迭代内一定会漂移。本文讲的就是我怎么把一个躺在仓库里的 YAML 文件,变成一道真正的闸门——一旦提供方不再兑现它对消费者许下的承诺,就直接把 PR 拦下来。
单元测试和集成测试一直漏掉的那个缺口
单元测试通过了,集成测试也通过了。然后某天,一个移动端在生产上崩了,就因为你那个服务返回的 status 字段从 "PAID" 变成了 "paid"——有人顺手把枚举重构时改了。没有一个测试抓住这个问题,因为根本就没有测试在盯着线上的报文结构。这就是契约测试要填的缺口。
我希望契约测试替其他测试层抓住的,是这些东西:字段漂移(改名、改类型、可空性翻转);响应结构的回归(某个消费者一直依赖 items[] 总是存在,结果现在空的时候就被省略了);错误码表面的变化(某个 409 变成了 422,只因为某个库升级改了默认行为);还有 header 层面的契约(幂等键、分页游标、content type)。这些问题基本都不会出现在一套飘绿的单元测试里。
消费者驱动 vs 提供者驱动:看谁在调你来决定
两派路线,两种我都上过线。提供者驱动的工具——比如 Schemathesis 和 Dredd——吃你的 OpenAPI 规格,然后用生成的请求狂轰运行中的服务器。消费者驱动的工具——比如 Pact——则反过来:每个消费者对着提供方的 mock 写测试,把交互发布到 broker,提供方每次构建时再用真实服务器去验证这些交互。
我的经验法则是:如果所有消费者都是你自己家的(一个移动 App、一个 Web、一个内部 worker),那就走消费者驱动,上 Pact。你得到的是正好覆盖消费者实际使用的那部分契约的测试——几乎永远只是完整 OpenAPI 表面的一小片。如果你跑的是一个面向未知消费者的公开 API,那就走提供者驱动,上 Schemathesis——你没有选择,只能把完整的声明契约整体校验一遍,因为任何人都可能在依赖任何字段。对我合作过的大多数内部平台团队,答案是"两个都要"——Pact 管已知消费者,Schemathesis 针对规格做模糊测试兜住其余所有。
让测试从规格生成,而不是手写
手写的契约测试会腐烂,生成的不会——因为源头是规格。我实际跑的管线是这样的:
- Schemathesis 读
openapi.yaml,生成基于属性的测试用例:每个端点、每个状态码分支、每个 schema 边界。一条命令,几千个用例。 - Prism 用同一份规格起一个 mock 服务器,让消费者测试在没有真实后端的情况下也能跑。关键是,Prism 还会拿规格来校验消费者发过来的请求——所以前端如果发了个畸形 body,mock 会直接拒掉。
- Dredd 在规格里带了显式
examples:块时,负责基于示例的检查。覆盖率不如 Schemathesis,但拿来把文档里那些 happy path 钉住挺有用。
我把这些生成出来的套件放在一个独立的 CI job 里,命名为 contract,和 unit、integration 分开——这样出问题时信号非常清楚:"报文结构动了。"
真正的敌人是脆弱的 fixture
想让一个被忍无可忍的团队删掉契约测试,最快的方法就是让它 flaky。我调过的每一个 flake,最后都能追到四种原因之一,而且这四种都有无聊但有效的解法。
- 非确定性 ID。把它们冻住。用已知的 UUID 预置测试数据库。永远不要对生成的
id做等值断言;用 schema matcher 去匹配^[0-9a-f-]{36}$。 - 时间。用 libfaketime 或者你语言里的等价物把时钟冻住。pact 要对着一个固定时刻录下来。任何
created_at的断言都该是类型检查,而不是值检查。 - 顺序。响应里的集合必须由提供方做稳定排序。如果你那个端点返回的顺序完全看数据库当天心情,契约测试会抓到它——但会以最糟糕的方式,间歇性 flake。
- 外部调用。契约测试验证的是你的契约,不是 Stripe 的契约。把所有第三方调用都 stub 掉。如果契约测试会联网,它的设计本身就是错的。
CI 闸门:提供方一旦偏离,就 fail 掉 PR
下面这套模式才是真的能改变行为的那种。OpenAPI 规格以 openapi.yaml 的形式签入提供方仓库。每次 PR 上的 contract job 会:
- 用确定性种子把服务起在测试数据库上。
- 用签入仓库的那份规格,对运行中的服务器跑 Schemathesis。
- 从 Pact Broker 拉取该提供方的最新 pact,然后逐一验证。
- 任何一项验证失败就 fail 构建,并在 PR 上留一条评论,说清楚是哪个消费者、哪个交互挂了。
这就是那道闸门。一个改 API 的开发者能在自己 PR 里直接看到:移动端那边的 checkout 流程需要 total_cents,而他的重构把它改成了 amount。他会在合并之前改掉,而不是下周二才从 Slack 消息里得知。
一个反馈回路跑起来的具体例子
上个季度,Web 团队想在订单确认页上显示一个 loyalty_tier 字段。他们在自己的 Pact 测试里把它加成了响应中期望的字段,跑了 pact publish,然后推了 PR。Web 那边的 PR 因为 feature flag 没开还不能合,但 pact 已经进 broker 了。
第二天,后端有个工程师在提供方仓库开了一个毫不相关的 PR。contract job 跑 broker 里的 pact,然后 fail 了:Web 消费者期望 loyalty_tier,但提供方没返回。那位后端工程师看到一条非常清晰的消息——"[email protected] expects loyalty_tier on GET /orders/{id}, not present in response"——他要么把字段实现了,要么去和 Web 团队协调,把那个 pact 加上版本 tag 给门起来。没有一个坏东西上线。消费者宣告了自己的需求,提供方必须回应。这就是整套做法真正的价值点。
用 Given/When/Then 写验收标准
我写契约的验收标准,形式和 BDD 场景一样——这种格式逼着我必须把消费者、触发条件、可观察结果都点名写出来。
Given the web-app consumer has published a pact expecting loyalty_tier
on GET /orders/{id}
When the provider CI job verifies the pact against the running server
Then the response body must include loyalty_tier as a string
And the field must be one of: "bronze", "silver", "gold", "platinum"
And the verification must fail the PR if the field is absent or malformed
规格生成的 mock vs staging 真实服务器:两个都跑
这里有个值得挑明的张力。一个从规格生成的 Prism mock,会很乐意返回规格说的任何东西——包括真实服务器从没产出过的结构。一个真实的 staging 服务器返回的是事实,但它慢、有状态。我两个都跑,按顺序:快反馈回路里跑 spec-mock 测试(每次 PR,一分钟以内);夜间 job 对着 staging 跑真实服务器验证。两边对不上时,不是规格错,就是实现错——这个分歧本身就是信号。
pact 的版本管理,以及失败时开发者看到什么
一个消费者、一个消费者版本,对应一个 pact 文件;在 broker 里用消费者的 git SHA 和环境打 tag(dev、prod)。提供方在自己的 main 分支 CI 里验证带 prod tag 的 pact,在 PR 构建时则验证全部 pact(包括未发布的消费者分支)。这样消费者可以尽情试验而不会把提供方的 main 搞挂,同时又能早早暴露未来的不兼容性。
契约测试在 CI 里挂掉时,开发者应该在 PR 评论里看到三件事:是哪个消费者、哪个版本坏了;确切是哪个交互(方法、路径、期望 body 与实际 body 的 diff);以及一个指向 broker 的链接,可以看到完整的 pact。不用去翻日志。失败要么自解释,要么就不值得拥有。
我绝不会省掉的部分
如果你什么都不做,也至少做这三件事:把 OpenAPI 规格签入仓库,在每个 PR 的 CI 上跑 Schemathesis 对它做校验,把规格和实现之间任何 schema 的 diff 都当作阻断性失败。仅仅这一条,就能抓住 70% 的漂移。等你有超过一个消费者、"改 API 前先打个招呼"这种协调成本开始吃掉迭代产能时,再加上 Pact。重点不是为覆盖率而覆盖率——重点是让规格承重,这样它才能一直为真。
现场笔记:有用的契约测试失败信息长什么样
契约测试只有在失败信息能让开发者快速行动时才有价值。我更喜欢 PR 评论直接写清消费者、端点、期望字段、实际响应和漂移的规格位置。
契约失败:
Consumer: [email protected]
Endpoint: GET /orders/{id}
Expected: response.body.refund_status enum includes "pending"
Actual: field missing
Spec: api/orders.yaml line 87
Action: add field, version the consumer expectation, or remove the pact before merge
这样可以避免最糟糕的契约测试体验:CI 变红,但工程师只能去日志里猜到底是哪份契约坏了。
可复制产物:契约评审包
当工作涉及 API 行为、schema、事件、重试或消费者预期时使用。它会把兼容性和发布证据提前摊开。
API 契约评审包:契约测试计划:从 OpenAPI 到 CI 本次要做的决策: - 确认契约变化是否兼容,消费者需要什么迁移动作,发布后如何观察风险。 责任人检查: - 产品责任人: - 工程责任人: - QA 或运维评审: 范围边界: - 本次包含: - 本次不包含: - 仍需确认的假设: 验收证据: - 测试或 fixture: - 日志、指标或截图: - 人工复核步骤: 契约边界:没有兼容性分类、消费者影响、重试行为和回滚说明,不进入发布。 评审追问: - 没参加需求会的人还会误解哪里? - 哪个证据能证明这次改动足够安全,可以发布?
旗舰使用路径
这是 Spec Coding 用来承接「契约测试落地」主题的核心参考页之一。建议把它放到真实工单、PR 或发布评审里使用,而不是只当背景文章阅读。
- 适合从这里开始:团队希望 schema 变化先在 CI 中失败,而不是集成时失败。
- 建议复制:OpenAPI 到 CI 的落地计划。
- 需要附上的证据:provider 检查、consumer fixture、失败样例输出和修复责任人。
- 搭配使用:API 契约 Hub 与 API 契约检查清单。
旗舰页使用路径: - 在计划或评审时打开本文。 - 把对应产物复制到工单或 PR。 - 用自己的系统、责任人和失败模式替换示例值。 - 如果证据行仍为空,就不要进入实现。
二次审阅记录:CI 要告诉开发者坏在哪里
这次复看时,我重点检查内容是否足够可落地。契约测试只有在失败信息能指出调用方、交互和下一步动作时,才值得占用 CI 时间。
CI 失败信息必须包含: - 调用方名称和版本 - 服务端 endpoint 和 method - 期望的请求或响应片段 - 实际片段 - spec 或 pact 位置 - 建议由谁修复
编辑复核记录
复核日期:2026-04-29。本次补充了可复用产物,按相关主题 Hub 检查了文章定位,并收紧下一步链接,让页面更像可操作参考,而不是孤立长文。
把契约测试接进现有流水线
中文团队落地契约测试时,先别追求一次性覆盖所有接口。可以从支付、权限、订单状态、Webhook 这类高风险接口开始,把 schema diff、mock 校验和一条消费者回归用例接进 CI。只要能在合并前拦下一次破坏性变更,这套机制就已经值回成本。
专题阅读路径
这篇文章归入 API 契约 主题。先读 Hub,再结合下面的清单、模板或工具落到具体项目里。
继续阅读
填写表单,生成完整的功能规格 Markdown——免费使用,无需注册。
编辑说明
最近复核:2026-04-29。编辑部检查了示例、内链和可复制评审片段,确保内容更适合真实项目使用。
本文面向软件交付团队,介绍契约测试计划:从 OpenAPI 到 CI。示例均为工程场景说明,不构成法律、税务或投资建议。
- 作者信息:Daniel Marsh
- 编辑政策:文章审阅与更新方式
- 纠错:联系编辑
本页合并覆盖的主题
为了让文章库更聚焦,这篇主文章现在作为「契约测试计划:从 OpenAPI 到 CI」的规范入口,同时覆盖下面这些原本分散的相关主题。读者可以在一个页面里完成判断、复制和评审,不必在多篇相似文章之间来回跳转。
- 复盘:缺失契约测试如何让破坏性变更进入生产环境
- 规格驱动的前后端对齐