当 Mock 开始撒谎:用契约测试守住并行开发的变更安全

53 阅读14分钟

当 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 个字段,但某个前端页面可能只依赖:

  1. 请求必须携带 page 与 pageSize;
  2. 响应必须存在 items 数组和 page.total;
  3. 每项必须存在 id、title、status;
  4. 未登录时必须返回可识别的认证失败响应。

消费者驱动契约测试(Consumer-Driven Contract Testing,CDCT)将这些实际依赖写成可执行测试。消费者构建时生成并发布契约;提供者构建时取回契约,在受控状态下将交互验证到真实应用入口,再发布验证结果。

以 Pact Broker 一类工具为例,它可以记录消费者版本、提供者版本及验证结果,并根据环境中的部署版本矩阵辅助判断是否可部署。

事项主要责任方共同责任
公共接口形状、领域语义、演进策略提供者 / API 设计责任人消费者评审可用性与兼容性
实际消费交互与最小依赖消费者提供者确认可实现且语义正确
Provider State 与实现验证提供者消费者说明场景意图
Mock 场景与测试夹具消费者可维护页面场景双方保证场景不脱离契约
破坏性变更与迁移窗口变更发起方所有受影响消费者确认迁移计划

因此,契约不是单点所有权,而是分层后的责任清晰化:提供者拥有公共能力的设计责任,消费者拥有“我真正依赖什么”的表达责任,双方共同对兼容性负责。

流程图展示 OpenAPI 公共接口规范如何驱动 Mock,消费者如何发布交互契约,提供者如何回放验证,以及版本验证结果如何进入部署兼容性门禁。

从需求到发布:一条可验证的并行协作流程

以“订单列表查询”为例:

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 做逐字段全等断言。这样会把无关字段也锁死,限制提供者安全扩展。

更好的做法是只断言页面真正依赖的部分:

// 伪代码:断言最小可用交互,而不是整个 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 上验证

提供者验证的价值,在于消费者交互会经过真实应用入口:路由、参数解析、认证、中间件、序列化与响应映射都应参与。

如果实现把:

{ "page": { "total": 120 } }

改成:

{ "page": { "count": 120 } }

而已有消费者依赖 total,提供者验证应失败。这并非限制后端重构,而是在指出:存在仍未迁移的真实依赖。

一份可行动的失败报告至少应展示:

  1. 受影响的消费者及其构建版本;
  2. 失败的交互与 Provider State;
  3. 请求或响应的具体差异;
  4. 失败源于消费者新增需求、提供者回退,还是公共规范与实现不同步。

最危险的漂移,是语义漂移

Mock 漂移至少有五种来源:

漂移类型常见表现应对机制
手写响应过期Mock 字段还在,真实字段已改名或删除由规范生成 Mock;规范变更触发更新
Schema 不一致类型、必填性、null 语义不同请求/响应 Schema 校验
场景缺失永远只有成功响应用消费者交互覆盖错误、空数据与边界状态
语义变更未声明字段未变,排序、分页或错误码含义变了契约评审加入语义说明与场景测试
版本错配新前端连接旧后端,或灰度版本交叉构建版本、环境记录与部署前检查

其中最危险的是语义漂移:JSON 结构完全合法,页面却悄悄做错事。

例如,后端为了性能,将 total 从精确总数改成近似值。如果契约只规定它是整数,Schema 校验仍可能通过;但前端若用它计算页码,用户就会看到错误分页。

因此,契约不仅要写结构,也要写影响消费行为的语义。必要时应改用更能表达语义的模型:

{
  "page": {
    "nextCursor": "abc123",
    "hasNextPage": true
  }
}

这是一项明确的 API 演进,而不是在旧字段中偷换含义。

兼容性要区分请求方向与响应方向

请求由消费者发送给提供者,响应由提供者发送给消费者。兼容性判断必须先区分方向。

变更默认兼容性判断建议
新增可选请求字段通常兼容提供者忽略未知字段或按默认值处理
新增必填请求字段通常不兼容新版本接口或保留兼容窗口
新增响应字段通常兼容,但依赖客户端忽略未知字段验证生成代码和严格解析器行为
删除或改名响应字段破坏性变更并存新旧字段,迁移后再废弃
类型收窄通常不兼容视为破坏性变更
枚举扩展不天然兼容明确 unknown / fallback 策略
错误码或错误体变化常为破坏性变更不应视为内部实现细节
分页、排序语义变化常为破坏性变更新增版本或迁移字段

“枚举新增一定兼容”是常见误判。以 Protobuf/gRPC 为例,协议层可以传输新增枚举值,但旧客户端收到未知值后仍必须能够正确运行,才算实际兼容。JSON API 也一样:消费者应将未知状态视为可预期输入,并提供明确降级策略。

CI/CD 需要三道门

门一:消费者构建——发布“我依赖什么”

消费者仓库在 Pull Request 或主干构建时输出:

  • 消费者契约;
  • 可追溯的消费者构建版本,例如 Git commit SHA;
  • 契约变更摘要;
  • 可选的公共 Schema 兼容性检查结果。

同一个版本号不应被不同契约内容反复覆盖,否则“这个版本是否验证过”的结论将失去可信度。

门二:提供者构建——证明“我仍然满足谁”

提供者代码、公共规范或消费者契约发生变化时:

  • 拉取仍受支持消费者的契约;
  • 准备 Provider State;
  • 在真实应用入口验证交互;
  • 发布逐版本验证结果;
  • 失败时输出差异及受影响消费者。

提供者不能只验证“最新前端”。生产中仍被支持的 Web 前端、移动端和外部集成方,都应纳入验证范围。

门三:部署前检查——证明“这个版本能否进入这个环境”

部署流水线应:

  • 将待部署版本与目标环境的已部署版本进行兼容性判断;
  • 缺少成功验证关系时阻断,或进入有记录的人工豁免流程;
  • 部署成功后记录版本已进入的环境。

核心不在于必须使用某个产品,而是保存三个事实:谁依赖谁、谁验证过谁、谁已经在哪个环境运行。

契约测试不能替代什么

契约测试适合回答:

  • 请求格式是否仍被提供者接受;
  • 响应状态码、头部、字段结构和关键约束是否满足;
  • 消费者依赖的错误路径是否仍存在;
  • 某个消费者版本是否已被某个提供者版本验证。

它不能独自证明:

  • 下单后库存是否真的扣减;
  • 多服务事务和异步消息最终是否一致;
  • 数据库、缓存、支付等真实依赖是否可靠;
  • 高并发下的延迟、吞吐与容量是否达标;
  • 用户从登录到支付完成的完整体验是否正确。

合理的组合应是:单元测试验证本地逻辑;消费者契约测试表达实际依赖;提供者验证证明接口未偏离;集成测试验证真实依赖;端到端测试验证关键路径;性能与安全测试验证非功能性风险。

最小落地标准

不必试图一次清理全部手写 Mock。优先选择一个前后端都可控、变更频繁、依赖清晰且曾出现联调偏差的接口试点。

最小闭环包括:

  1. 一份版本化公共接口规范,描述请求、响应、错误、认证和关键语义;
  2. 一个由规范生成或受规范校验的 Mock 入口;
  3. 一组消费者交互测试,覆盖成功与关键失败路径;
  4. 一组在真实应用入口执行的提供者验证;
  5. 一个基于构建版本、验证结果和环境版本的部署前兼容性门禁。

Mock 的价值从来不是“假装后端已经完成”,而是让前端在真实服务尚未完成时,先对一份明确、可验证、可演进的承诺开始开发。

当 Mock、规范、消费者测试、提供者验证和部署门禁围绕同一份协作约束运转时,前后端并行不再依赖“联调前不要改接口”的脆弱默契,而是拥有一种更可靠的能力:允许变化发生,同时让不兼容的变化尽早、明确且可归因地失败。

参考资料