别把联调当验收:用可执行契约管住接口变更
前后端并行时,最常见的误解是:前端有 Mock、后端有接口文档,等接口写完再联调即可。
问题在于,Mock 只能让前端继续开发,接口文档只能表达约定;二者都不能自动证明真实服务是否兑现了约定。于是,一个看似顺畅的并行流程,往往会在联调阶段集中暴露问题:
- 前端按 Mock 渲染了
totalAmount,真实接口却返回amount; - 页面把
401视为登录过期,后端却在权限不足时也返回401; - Mock 允许
pageSize缺省,真实服务上线后把它改成必填; - 字段类型没有变,但时间从"本地时区字符串"变成了"UTC 时间戳",报表日期整体错位。
这些问题的本质不是"联调不充分",而是接口变更没有经历可执行的兼容性证明。
本文给出一套从 Mock 走向契约测试的工程化流程。目标不是让团队引入更多工具,而是建立一个清晰闭环:谁提出接口变化,谁维护机器可读契约,谁验证真实实现,以及什么情况下 CI 必须拒绝发布。

一、先分清四件事:需求、契约、Mock 与真实实现
很多团队把接口文档、Mock 数据和契约测试混为一谈。实际上,它们分别回答不同的问题。
| 对象 | 它解决的问题 | 不能证明什么 |
|---|---|---|
| 接口需求 | 业务需要什么能力 | 请求与响应是否可被程序稳定消费 |
| 机器可读契约 | 调用方和提供方应如何交互 | 真实服务是否已经按契约实现 |
| Mock | 服务未完成时,消费者如何继续开发和测试 | 线上服务是否会返回相同结果 |
| 提供者验证 | 真实服务能否满足消费者依赖 | 完整业务链路、性能与外部依赖是否正确 |
OpenAPI 适合作为统一的接口描述入口:它以语言无关的方式描述 HTTP API,可被文档、代码生成和测试工具消费。规范覆盖路径、参数、请求体、响应及安全机制等核心对象。(spec.openapis.org)
因此,推荐把协作关系改成下面这样:
需求说明业务意图;契约定义交互边界;Mock 服务于消费者开发;真实服务通过验证证明它没有偏离契约。
这个顺序很重要。若先写页面、再临时造 Mock、最后补文档,团队维护的是三份可能互相矛盾的事实;若契约成为共同输入,Mock、类型、文档和校验才有机会围绕同一份定义运转。
二、一个可落地的默认流程:契约先行,但不要求后端停工
对于多数前后端分离团队,适合采用"契约先行,按需补充消费者驱动验证"的路径。它既不要求所有接口都使用 Pact,也不要求后端必须在需求评审当天完成实现。
以"订单列表页"新增筛选条件为例,流程可以拆为八步。
1. 需求确认:先写清字段语义,而不只是字段名称
产品、前端和后端先确认以下问题:
createdAt是 UTC 时间、带时区的 ISO 8601 字符串,还是用户本地日期?totalAmount的单位是元、分,还是带币种的金额对象?- 无数据时返回空数组、
null,还是404? - 游标分页的
nextCursor为空时,代表没有下一页还是请求参数无效? - 登录失效、权限不足、风控拦截是否使用不同的状态码和业务错误码?
这里的产物不是"字段列表",而是一段能被评审的语义说明。类型相同不代表语义相同;字段语义没有写清,后续即使 Schema 校验全部通过,也仍可能发生业务错误。
2. 契约定义:将稳定交互写入 OpenAPI
契约至少应包含:
- 路径、HTTP 方法和参数位置;
- 请求参数的类型、必填性、序列化方式,以及服务端实际采用的默认行为;
- 请求体与成功响应的结构;
- 已知失败场景对应的状态码、业务错误码和错误体;
- 鉴权方式与匿名访问边界;
- 分页、排序、筛选、幂等键及重试语义;
- 空值、缺失字段、未知枚举值、金额单位和时间语义。
尤其不要只写 200 响应。OpenAPI 能够对操作参数、请求体、响应和安全要求建模;把错误响应与鉴权遗漏在契约外,往往意味着前端只能在联调时猜测异常分支。需要注意的是,OpenAPI 中的 default 是描述层面的默认值声明,不应替代对服务端实际缺省行为的测试。(spec.openapis.org)
3. 契约评审:由调用方确认"我真的这样依赖吗"
评审不应只是后端自审 YAML。建议明确四类责任:
| 角色 | 核心责任 |
|---|---|
| 需求提出者 | 说明业务场景、状态与边界条件 |
| 提供者负责人 | 保证接口设计、实现和兼容策略可行 |
| 消费者负责人 | 确认字段、错误分支与交互方式满足页面或服务需要 |
| 测试或技术负责人 | 关注跨端影响、发布顺序与门禁规则 |
评审的关键问题不是"文档是否完整",而是:消费者是否能仅凭该契约完成开发,并正确处理成功、空态、失败和权限状态。
4. Mock:让前端提前验证状态,而不是提前伪造成功
Mock 的价值是隔离未完成或不稳定的网络依赖,使前端可以开发页面、组件和自动化测试。以 MSW 为例,它能够在浏览器和 Node.js 环境中拦截请求,并让同一份 Mock 定义复用于不同开发与测试环境。(mswjs.io)
但 Mock 应覆盖状态集合,而不是只返回一份"漂亮的成功数据":
- 正常列表与空列表;
- 参数非法;
- 未登录、会话过期、权限不足;
- 服务端业务拒绝;
- 分页结束与重复请求;
- 网络超时或暂时性失败。
团队约定上,应优先让 Mock 从契约生成,或至少在契约变更时同步更新。手写 Mock 可以作为过渡方案,但不能成为唯一事实来源。
5. 消费者测试:声明"页面真正需要什么"
OpenAPI 擅长描述统一的接口表面,但它不一定能发现"某个页面依赖了某个可选字段的特定组合"。这时可以为关键消费者补充消费者驱动契约测试。
消费者驱动契约的重点不是复制一份完整响应,而是记录消费者真正依赖的最小交互:它会发送什么请求、在什么前置状态下调用、需要哪些响应字段和错误行为。随后,提供者在自己的本地或 CI 环境中回放这些交互,验证真实服务是否满足该依赖。(docs.pact.io)
例如,订单列表页并不需要"订单对象的全部字段",但它可能明确依赖:
text
GET /orders?status=paid&cursor=abc
200:
- items 必须是数组
- 每项必须提供 id、createdAt、totalAmount、currency
- nextCursor 可为 null
401:
- error.code 必须是 SESSION_EXPIRED
这种契约比"响应符合某个大 Schema"更接近实际调用风险:它能够暴露提供者删除字段、改变错误码、收紧请求校验等问题。
6. 提供者验证:校验真实入口,而不是绕开关键校验
提供者验证应尽可能通过真实 HTTP 路由、请求解析和输入校验发起请求,并准备相应的 provider state,例如"存在一笔已支付订单"。鉴权本身是否纳入验证,取决于测试环境与安全边界;但鉴权规则及其错误响应仍应在契约中明确。
如果需要 Stub 下游系统,应避免在请求尚未经过路由、解析或输入校验时就短路返回;否则服务可能接受任意错误输入,测试仍然通过。(docs.pact.io)
因此,提供者验证的失败应按归属处理:
- 契约错误:消费者依赖了不应公开的行为,或双方尚未达成一致;
- 实现错误:真实接口没有满足已评审契约;
- 测试数据错误:provider state 未正确构造,不能代表声明场景;
- 兼容性错误:新实现破坏了仍在使用的旧消费者。
不要把所有失败都交给前端"改适配"。适配可以处理短期发布错峰,但不能替代对接口责任的判断。
7. 联调抽样:从主验证手段退回到体验确认
引入契约校验后,联调仍然必要,但角色应改变。
联调适合确认:页面交互是否自然、跨接口状态是否一致、网关与鉴权链路是否正确、真实环境配置是否生效。它不应再承担"第一次发现字段名写错、状态码不一致、必填参数变更"的职责。
换句话说:联调是对环境和体验的抽样确认,不是接口兼容性的唯一验收。
8. 发布门禁:检查待发布版本,而不是只看主干是否为绿
若契约验证只停留在 CI 报告中,最后仍可能出现前端先发、后端未发,或后端升级后破坏生产旧前端的情况。
Pact Broker 一类的契约仓库会保存消费者契约、提供者验证结果和版本关系;部署前可通过 can-i-deploy 检查待发布版本是否已与目标环境中实际存在的依赖版本完成成功验证。(docs.pact.io)
这使发布门禁从"最新分支是否通过"变成一个更准确的问题:
我要部署的这个版本,能否与目标环境里仍在运行的那些版本一起工作?
三、OpenAPI 与消费者驱动契约,不是二选一
两者解决的粒度不同,最实用的组合通常是:
| 层次 | 推荐机制 | 主要解决的问题 |
|---|---|---|
| 统一接口描述 | OpenAPI | 路径、参数、请求体、响应、鉴权与基础 Schema 是否明确 |
| 本地并行开发 | 基于契约的 Mock | 前端能否在服务未完成时推进界面与状态处理 |
| 关键消费者依赖 | 消费者驱动契约 | 真实提供者是否满足某个消费者实际依赖的交互 |
| 全链路体验 | 集成测试与端到端测试 | 多服务协同、环境配置和关键业务路径是否可用 |
OpenAPI 不应被误认为"只是一份文档";它可以成为生成 Mock、客户端代码和自动化校验的共同输入。消费者驱动契约也不应被误认为"替代所有接口测试";它更适合保护高价值、跨仓库、独立发布的消费者---提供者关系。(spec.openapis.org)
一个务实的迁移顺序是:
- 先收敛接口定义入口,避免文档、Mock、代码各自维护;
- 对 OpenAPI 执行格式与差异校验;
- 让 Mock 与契约保持可追溯同步;
- 选择订单、登录态、支付结果等高风险接口接入消费者驱动契约;
- 最后把验证结果接入部署门禁。
不要一开始就要求每个内部接口都写细粒度 Pact。先覆盖发布节奏不同、调用方多、变更频繁或故障代价高的接口,收益更明显。
四、接口变更先分类,再决定怎么改
接口治理最容易失败的原因,是把所有变更都当作"改一下字段"。实际上,变化至少应分为四类。
| 变更类型 | 例子 | 默认策略 |
|---|---|---|
| 兼容性新增 | 增加可选请求参数、增加响应字段、新增操作 | 标记为新增;消费者保持忽略未知字段的能力 |
| 破坏性变更 | 删除或重命名字段、字段类型变化、可选参数改必填、鉴权规则收紧 | 新版本、双轨支持或明确迁移窗口 |
| 行为语义变更 | 金额单位变化、时区变化、枚举含义改变、错误码含义改变 | 视为破坏性变更,必须更新语义说明与消费者验证 |
| 非功能性风险 | 限流策略变化、超时变化、分页上限降低、幂等行为变化 | 单独评审,并通过压测、监控或回归验证 |
GitHub 的 API 变更文档将删除或重命名参数或响应字段、增加必填参数、改变字段类型、收紧校验与认证要求列为破坏性变更;相对地,增加可选参数或响应字段通常属于追加式变化。(docs.github.com) Microsoft 的 API 设计指南也建议尽可能保持向后兼容;当确实需要破坏性变更时,应引入新 API 版本并继续支持旧版本一段时间。(learn.microsoft.com)
这里有一个容易被忽略的原则:
字段类型不变,不代表接口兼容。
status: "closed" 从"用户主动关闭"扩展为"系统风控关闭",JSON Schema 依然可以通过,页面文案和后续操作却可能完全错误。对此,除了枚举描述,还应在契约评审中要求写出状态来源、允许动作和面向用户的含义;对关键状态,应由消费者测试覆盖实际分支。
五、把流程接进 CI/CD:让不兼容变更尽早失败
一套轻量但完整的流水线可以按三个时点部署。
提交与合并请求阶段
- 校验 OpenAPI 文件格式、引用和基础 Schema;
- 对比契约差异,自动标记删除字段、字段类型变化、必填性变化和安全要求变化;
- 要求破坏性变更附带版本策略、迁移说明和弃用日期;
- 运行消费者 Mock 测试,确保页面在成功、空态和错误态下可工作。
消费者与提供者 CI 阶段
- 消费者 CI 生成并发布契约;
- 提供者 CI 拉取相关契约,在本地启动的服务上执行验证;
- 发布验证结果,并让失败直接阻断合并或进入待处理队列;
- 对长期存量消费者,提供者验证不只覆盖最新版本,也覆盖目标环境仍在运行的关键版本。
Pact 的官方流程强调提供者验证应在本地或 CI 中运行,并将验证结果回传;仅共享契约而没有共享验证结果,无法为消费者部署提供足够信心。(docs.pact.io)
部署阶段
- 部署前检查目标环境中的版本兼容矩阵;
- 校验失败则禁止部署,不以人工口头确认替代;
- 部署成功后记录当前环境实际运行的消费者和提供者版本;
- 对弃用中的版本建立可见的使用清单与退出期限。
can-i-deploy 的价值正在于此:它依据已发布的契约和验证结果,判断候选版本与某个环境中已有依赖版本是否兼容,而不是仅检查"最新版本彼此是否测试过"。(docs.pact.io)
六、四种常见失效模式,以及应把检测点放在哪里
1. Mock 与真实接口不一致
表现:前端开发完成,联调才发现字段、状态码或分页结构不同。
根因:Mock 从前端临时需求产生,真实实现从后端代码产生,两者没有共同来源。
修复:让 Mock 从契约生成,或至少把 Mock 更新纳入契约变更的合并条件;关键接口再增加提供者验证。
2. 契约文件长期过期
表现:文档看似完整,真实接口早已新增字段或改变校验规则。
根因:契约是发布后的补充材料,而不是代码变更的一部分。
修复:将契约文件与服务代码放在同一变更中;合并请求要求同时更新实现、契约、Mock 或测试,并由消费者审核变化。
3. 字段未变,语义已经漂移
表现:类型校验和 Mock 测试均通过,但页面金额、日期、状态文案或操作按钮错误。
根因:团队只维护结构,没有维护单位、时区、状态机和错误语义。
修复:把"字段语义"设为契约的必填描述项;关键状态通过消费者用例验证,而不是只断言字段存在。
4. 只测 Schema,不测真实输入校验
表现:服务能返回看似正确的响应,但非法请求体也被接受,或鉴权行为与约定不符。
根因:提供者测试在过早位置 Stub 了内部逻辑,绕开了真实请求解析与校验。
修复 :提供者验证应经过真实 HTTP 路由、请求解析与输入校验;如需隔离下游,只在这些边界之后 Stub。(docs.pact.io)
七、一份团队可以直接采用的约定
如果团队当前仍以手写 Mock 和人工联调为主,可以先执行以下最小规则:
- 每个对外或跨仓库接口有唯一的机器可读契约入口。
- 契约同时描述成功、空态、已知错误和鉴权要求。
- 每个字段有类型之外的语义:单位、时区、
null与缺失含义、枚举扩展策略。 - Mock 与契约存在生成或明确同步关系,不允许独立漂移。
- 接口变更在合并前标注为兼容性新增、破坏性变更、行为语义变更或非功能性风险。
- 关键消费者具备可回放的交互契约,提供者在 CI 中验证真实实现。
- 发布前检查候选版本与目标环境现存版本的兼容性。
- 破坏性变更必须有版本、迁移窗口、弃用通知和旧版本退出条件。
结语
Mock 的意义不是制造一个"看起来能用"的后端;它的意义是让消费者在依赖尚未就绪时继续交付。契约测试的意义也不是增加一层测试名词;它是把"接口应该一致"变成可执行、可追责、可阻断发布的工程事实。
当团队能够持续回答下面三个问题时,前后端并行才真正具备稳定性:
- 当前 Mock 依据的到底是哪份契约?
- 真实服务是否已经验证满足这份契约?
- 待发布版本是否仍兼容目标环境中正在运行的消费者与提供者?
联调不会消失,但它不该继续成为发现接口基本不一致的最后防线。