当 Mock 开始撒谎:用契约测试守住并行开发的变更安全
前后端并行开发往往从接口文档和几段 Mock 数据开始。
这能解除等待:前端不必等后端接口上线,后端也不必等页面完成才知道数据是否够用。但进入联调后,团队常会发现:Mock 曾经让开发继续前进,也可能让错误更晚暴露。
例如:
- Mock 中
total永远是数字,真实接口在某些查询条件下却返回null; - Mock 只提供
200,真实服务在会话过期时返回不同的状态码或错误体; - 前端按既有字符串枚举渲染状态,后端新增了一个枚举值;
- 文档写的是可选字段,后端实现却把它变成必填;
- 前端已经依赖分页稳定排序,后端却把分页语义从偏移量改为游标,而路径与字段名没有明显变化。
这些问题通常不是"联调不够仔细",而是团队把可供开发的模拟物 误当成了可被验证的协作约束。
本文讨论的不是再增加一套接口文档流程,而是把接口协作变成可执行的工程链路:
契约定义什么可以依赖;Mock 让依赖可以被提前使用;验证证明真实实现没有偏离;部署门禁阻止不兼容版本进入同一环境。
先区分六件容易混为一谈的事
很多团队说"我们已经有契约了",实际指的可能只是 Swagger 文档、TypeScript 类型,或本地的一份 JSON Mock。它们都重要,但解决的问题不同。
| 能力 | 验证对象 | 主要价值 | 不能保证什么 |
|---|---|---|---|
| 接口文档 | 接口设计说明 | 让人理解路径、参数、响应与示例 | 真实实现是否遵守 |
| 静态 Mock | 预设响应 | 让前端脱离后端先开发 | 错误路径、边界条件与真实行为 |
| Schema 校验 | 数据结构规则 | 发现字段、类型、必填性偏差 | 消费者是否真的依赖这些字段语义 |
| 消费者契约测试 | 消费者的请求与预期响应 | 记录消费者实际使用的交互 | 提供者内部业务逻辑是否正确 |
| 提供者契约验证 | 真实应用对契约交互的响应 | 证明当前实现满足已发布契约 | 完整用户流程、性能与外部依赖可靠性 |
| 集成测试 / 端到端测试 | 多模块或完整流程 | 验证副作用、权限链路与环境协作 | 快速、细粒度地定位每一份接口承诺 |
OpenAPI 是机器可读的接口描述,可承载路径、请求、响应和 Schema。它可以驱动文档、Mock、代码生成与请求/响应校验,但"存在描述文件"不等于"实现已经被证明符合描述"。
尤其在 OpenAPI 3.1 中,Schema Object 与 JSON Schema 2020-12 及 OpenAPI Base Dialect 有关;团队应确认实际工具支持的 OpenAPI 版本、JSON Schema 方言和组合语义,不能仅凭文件扩展名推断校验行为。
有 Mock,不等于有契约;有契约文件,也不等于有契约验证。
把契约拆成"公共形状"与"真实依赖"
对于 REST API,一个实用的治理模型不是把所有规则塞进一个文件,而是分成两层。
第一层:公共形状契约
公共形状回答的是:接口整体允许什么。
它通常由版本化 OpenAPI 文档承担,包括:
- 路径、方法与认证方式;
- 请求参数、请求体和响应体的 Schema;
- 状态码及错误结构;
- 分页、排序、筛选等通用约定;
- 示例、默认值、弃用标记和版本信息。
这层适合成为 REST 接口的主要事实源。由它生成文档、基础类型、Mock,或对请求和响应进行校验,可以减少"文档一份、类型一份、Mock 一份"的长期分叉。
第二层:消费者交互契约
公共形状无法回答另一个关键问题:某个具体消费者到底依赖什么?
例如,列表接口定义了 30 个字段,但某个前端页面可能只依赖:
- 请求必须携带
page与pageSize; - 响应必须存在
items数组和page.total; - 每项必须存在
id、title、status; - 未登录时必须返回可识别的认证失败响应。
消费者驱动契约测试(Consumer-Driven Contract Testing,CDCT)将这些实际依赖写成可执行测试。消费者构建时生成并发布契约;提供者构建时取回契约,在受控状态下将交互验证到真实应用入口,再发布验证结果。
以 Pact Broker 一类工具为例,它可以记录消费者版本、提供者版本及验证结果,并根据环境中的部署版本矩阵辅助判断是否可部署。
| 事项 | 主要责任方 | 共同责任 |
|---|---|---|
| 公共接口形状、领域语义、演进策略 | 提供者 / API 设计责任人 | 消费者评审可用性与兼容性 |
| 实际消费交互与最小依赖 | 消费者 | 提供者确认可实现且语义正确 |
| Provider State 与实现验证 | 提供者 | 消费者说明场景意图 |
| Mock 场景与测试夹具 | 消费者可维护页面场景 | 双方保证场景不脱离契约 |
| 破坏性变更与迁移窗口 | 变更发起方 | 所有受影响消费者确认迁移计划 |
因此,契约不是单点所有权,而是分层后的责任清晰化:提供者拥有公共能力的设计责任,消费者拥有"我真正依赖什么"的表达责任,双方共同对兼容性负责。

从需求到发布:一条可验证的并行协作流程
以"订单列表查询"为例:
http
GET /v1/orders?page=1&pageSize=20&status=PAID
不要只定义成功响应,还应明确:
page、pageSize的合法范围;- 排序是否稳定;
- 空列表是否为
200 + items: []; - 未登录、无权限、参数非法分别返回什么状态码和错误结构;
status的可选值、未知值策略与前端降级方式;total是精确总数、估算总数,还是在某些条件下不可得;- 列表项字段是否允许
null,以及null的业务含义。
关键不在于"把文档写长",而是把前端会据此编写分支逻辑的行为显式化。未被表达的行为,通常会在联调阶段变成争议,而不是测试失败。
1. 用公共契约生成或约束 Mock
Mock 的职责是提供一个受契约约束的开发入口,而不是凭经验拼一段"看起来像真的"响应。
基于 OpenAPI 的 Mock 服务和验证代理可以从接口描述生成模拟响应,或将请求、响应与规范进行比较。以 Prism 为例,它提供 Mock Server 和 Validation Proxy 等能力,可用于在开发阶段发现流量与规范之间的不一致。
一个够用的 Mock 至少应覆盖:
- 正常分页:有数据、有下一页;
- 空结果:
items: [],同时保留完整分页对象; - 边界值:最大
pageSize、最后一页、非法参数; - 鉴权失败:未登录、令牌过期、权限不足;
- 服务失败:稳定且可识别的错误结构;
- 领域状态:取消订单、退款中订单、未知枚举值;
- 延迟与加载态:用于验证取消请求、重试和骨架屏行为。
字段类型、必填性、状态码与错误结构应来自公共契约;页面特有的状态组合可以作为消费者测试夹具补充,但不应反过来成为唯一真相。
2. 前端以消费者测试固定"真正需要的交互"
前端不应对整个响应 DTO 做逐字段全等断言。这样会把无关字段也锁死,限制提供者安全扩展。
更好的做法是只断言页面真正依赖的部分:
ts
// 伪代码:断言最小可用交互,而不是整个 DTO
expect(request).toMatch({
method: 'GET',
path: '/v1/orders',
query: { page: '1', pageSize: '20', status: 'PAID' }
})
expect(response).toMatch({
status: 200,
body: {
items: eachLike({
id: string(),
title: string(),
status: oneOf('PAID', 'REFUNDING', 'CANCELLED')
}),
page: {
current: integer(),
pageSize: integer(),
total: integer()
}
}
})
同时,应为非成功路径建立独立交互:
- 无
orders:read权限时返回403; - 会话失效时返回约定的认证失败响应;
- 筛选条件非法时返回
400,且错误字段可被表单定位。
同一请求在不同资源状态下可能得到不同响应。提供者验证需要借助 Provider State 或等价机制准备这些状态,而不是只命中一份固定数据库数据。若必须替换下游依赖,应尽量在真实请求已经经过路由、解析和关键校验之后再替换,避免验证被过早的 stub 架空。
3. 后端在真实应用入口验证,而不是在 Controller Mock 上验证
提供者验证的价值,在于消费者交互会经过真实应用入口:路由、参数解析、认证、中间件、序列化与响应映射都应参与。
如果实现把:
json
{ "page": { "total": 120 } }
改成:
json
{ "page": { "count": 120 } }
而已有消费者依赖 total,提供者验证应失败。这并非限制后端重构,而是在指出:存在仍未迁移的真实依赖。
一份可行动的失败报告至少应展示:
- 受影响的消费者及其构建版本;
- 失败的交互与 Provider State;
- 请求或响应的具体差异;
- 失败源于消费者新增需求、提供者回退,还是公共规范与实现不同步。
最危险的漂移,是语义漂移
Mock 漂移至少有五种来源:
| 漂移类型 | 常见表现 | 应对机制 |
|---|---|---|
| 手写响应过期 | Mock 字段还在,真实字段已改名或删除 | 由规范生成 Mock;规范变更触发更新 |
| Schema 不一致 | 类型、必填性、null 语义不同 |
请求/响应 Schema 校验 |
| 场景缺失 | 永远只有成功响应 | 用消费者交互覆盖错误、空数据与边界状态 |
| 语义变更未声明 | 字段未变,排序、分页或错误码含义变了 | 契约评审加入语义说明与场景测试 |
| 版本错配 | 新前端连接旧后端,或灰度版本交叉 | 构建版本、环境记录与部署前检查 |
其中最危险的是语义漂移:JSON 结构完全合法,页面却悄悄做错事。
例如,后端为了性能,将 total 从精确总数改成近似值。如果契约只规定它是整数,Schema 校验仍可能通过;但前端若用它计算页码,用户就会看到错误分页。
因此,契约不仅要写结构,也要写影响消费行为的语义。必要时应改用更能表达语义的模型:
json
{
"page": {
"nextCursor": "abc123",
"hasNextPage": true
}
}
这是一项明确的 API 演进,而不是在旧字段中偷换含义。
兼容性要区分请求方向与响应方向
请求由消费者发送给提供者,响应由提供者发送给消费者。兼容性判断必须先区分方向。
| 变更 | 默认兼容性判断 | 建议 |
|---|---|---|
| 新增可选请求字段 | 通常兼容 | 提供者忽略未知字段或按默认值处理 |
| 新增必填请求字段 | 通常不兼容 | 新版本接口或保留兼容窗口 |
| 新增响应字段 | 通常兼容,但依赖客户端忽略未知字段 | 验证生成代码和严格解析器行为 |
| 删除或改名响应字段 | 破坏性变更 | 并存新旧字段,迁移后再废弃 |
| 类型收窄 | 通常不兼容 | 视为破坏性变更 |
| 枚举扩展 | 不天然兼容 | 明确 unknown / fallback 策略 |
| 错误码或错误体变化 | 常为破坏性变更 | 不应视为内部实现细节 |
| 分页、排序语义变化 | 常为破坏性变更 | 新增版本或迁移字段 |
"枚举新增一定兼容"是常见误判。以 Protobuf/gRPC 为例,协议层可以传输新增枚举值,但旧客户端收到未知值后仍必须能够正确运行,才算实际兼容。JSON API 也一样:消费者应将未知状态视为可预期输入,并提供明确降级策略。
CI/CD 需要三道门
门一:消费者构建------发布"我依赖什么"
消费者仓库在 Pull Request 或主干构建时输出:
- 消费者契约;
- 可追溯的消费者构建版本,例如 Git commit SHA;
- 契约变更摘要;
- 可选的公共 Schema 兼容性检查结果。
同一个版本号不应被不同契约内容反复覆盖,否则"这个版本是否验证过"的结论将失去可信度。
门二:提供者构建------证明"我仍然满足谁"
提供者代码、公共规范或消费者契约发生变化时:
- 拉取仍受支持消费者的契约;
- 准备 Provider State;
- 在真实应用入口验证交互;
- 发布逐版本验证结果;
- 失败时输出差异及受影响消费者。
提供者不能只验证"最新前端"。生产中仍被支持的 Web 前端、移动端和外部集成方,都应纳入验证范围。
门三:部署前检查------证明"这个版本能否进入这个环境"
部署流水线应:
- 将待部署版本与目标环境的已部署版本进行兼容性判断;
- 缺少成功验证关系时阻断,或进入有记录的人工豁免流程;
- 部署成功后记录版本已进入的环境。
核心不在于必须使用某个产品,而是保存三个事实:谁依赖谁、谁验证过谁、谁已经在哪个环境运行。
契约测试不能替代什么
契约测试适合回答:
- 请求格式是否仍被提供者接受;
- 响应状态码、头部、字段结构和关键约束是否满足;
- 消费者依赖的错误路径是否仍存在;
- 某个消费者版本是否已被某个提供者版本验证。
它不能独自证明:
- 下单后库存是否真的扣减;
- 多服务事务和异步消息最终是否一致;
- 数据库、缓存、支付等真实依赖是否可靠;
- 高并发下的延迟、吞吐与容量是否达标;
- 用户从登录到支付完成的完整体验是否正确。
合理的组合应是:单元测试验证本地逻辑;消费者契约测试表达实际依赖;提供者验证证明接口未偏离;集成测试验证真实依赖;端到端测试验证关键路径;性能与安全测试验证非功能性风险。
最小落地标准
不必试图一次清理全部手写 Mock。优先选择一个前后端都可控、变更频繁、依赖清晰且曾出现联调偏差的接口试点。
最小闭环包括:
- 一份版本化公共接口规范,描述请求、响应、错误、认证和关键语义;
- 一个由规范生成或受规范校验的 Mock 入口;
- 一组消费者交互测试,覆盖成功与关键失败路径;
- 一组在真实应用入口执行的提供者验证;
- 一个基于构建版本、验证结果和环境版本的部署前兼容性门禁。
Mock 的价值从来不是"假装后端已经完成",而是让前端在真实服务尚未完成时,先对一份明确、可验证、可演进的承诺开始开发。
当 Mock、规范、消费者测试、提供者验证和部署门禁围绕同一份协作约束运转时,前后端并行不再依赖"联调前不要改接口"的脆弱默契,而是拥有一种更可靠的能力:允许变化发生,同时让不兼容的变化尽早、明确且可归因地失败。