契约测试计划:从 OpenAPI 到 CI

契约测试计划:从 OpenAPI 到 CI
Daniel Marsh · Spec-First 工程笔记

我的原则是:如果一份 OpenAPI 规格没有作为可执行契约接入 CI,那它就只是装饰品。没有任何测试去对它做断言的规格,两个迭代内一定会漂移。本文讲的就是我怎么把一个躺在仓库里的 YAML 文件,变成一道真正的闸门——一旦提供方不再兑现它对消费者许下的承诺,就直接把 PR 拦下来。

发布于 2026-03-12 · ✓ 已更新 2026-05-11 · 阅读约 8 分钟 · 作者:Daniel Marsh · 审校:编辑政策

单元测试和集成测试一直漏掉的那个缺口

单元测试通过了,集成测试也通过了。然后某天,一个移动端在生产上崩了,就因为你那个服务返回的 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 针对规格做模糊测试兜住其余所有。

让测试从规格生成,而不是手写

手写的契约测试会腐烂,生成的不会——因为源头是规格。我实际跑的管线是这样的:

我把这些生成出来的套件放在一个独立的 CI job 里,命名为 contract,和 unitintegration 分开——这样出问题时信号非常清楚:"报文结构动了。"

真正的敌人是脆弱的 fixture

想让一个被忍无可忍的团队删掉契约测试,最快的方法就是让它 flaky。我调过的每一个 flake,最后都能追到四种原因之一,而且这四种都有无聊但有效的解法。

CI 闸门:提供方一旦偏离,就 fail 掉 PR

下面这套模式才是真的能改变行为的那种。OpenAPI 规格以 openapi.yaml 的形式签入提供方仓库。每次 PR 上的 contract job 会:

  1. 用确定性种子把服务起在测试数据库上。
  2. 用签入仓库的那份规格,对运行中的服务器跑 Schemathesis。
  3. 从 Pact Broker 拉取该提供方的最新 pact,然后逐一验证。
  4. 任何一项验证失败就 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(devprod)。提供方在自己的 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 或发布评审里使用,而不是只当背景文章阅读。

旗舰页使用路径:
- 在计划或评审时打开本文。
- 把对应产物复制到工单或 PR。
- 用自己的系统、责任人和失败模式替换示例值。
- 如果证据行仍为空,就不要进入实现。

二次审阅记录:CI 要告诉开发者坏在哪里

这次复看时,我重点检查内容是否足够可落地。契约测试只有在失败信息能指出调用方、交互和下一步动作时,才值得占用 CI 时间。

CI 失败信息必须包含:
- 调用方名称和版本
- 服务端 endpoint 和 method
- 期望的请求或响应片段
- 实际片段
- spec 或 pact 位置
- 建议由谁修复

编辑复核记录

复核日期:2026-04-29。本次补充了可复用产物,按相关主题 Hub 检查了文章定位,并收紧下一步链接,让页面更像可操作参考,而不是孤立长文。

把契约测试接进现有流水线

中文团队落地契约测试时,先别追求一次性覆盖所有接口。可以从支付、权限、订单状态、Webhook 这类高风险接口开始,把 schema diff、mock 校验和一条消费者回归用例接进 CI。只要能在合并前拦下一次破坏性变更,这套机制就已经值回成本。

关键词:contract testing · OpenAPI · Pact · Schemathesis · consumer-driven contracts · CI pipeline

专题阅读路径

这篇文章归入 API 契约 主题。先读 Hub,再结合下面的清单、模板或工具落到具体项目里。

交互式生成规格
填写表单,生成完整的功能规格 Markdown——免费使用,无需注册。
试用规格生成器

编辑说明

最近复核:2026-04-29。编辑部检查了示例、内链和可复制评审片段,确保内容更适合真实项目使用。

本文面向软件交付团队,介绍契约测试计划:从 OpenAPI 到 CI。示例均为工程场景说明,不构成法律、税务或投资建议。

本页合并覆盖的主题

为了让文章库更聚焦,这篇主文章现在作为「契约测试计划:从 OpenAPI 到 CI」的规范入口,同时覆盖下面这些原本分散的相关主题。读者可以在一个页面里完成判断、复制和评审,不必在多篇相似文章之间来回跳转。

  • 复盘:缺失契约测试如何让破坏性变更进入生产环境
  • 规格驱动的前后端对齐