Contract Testing Knowledge Share
契约开发学习成果分享
从“联调拉扯”到“契约协同”
这套分享聚焦契约精神:谁消费谁定义、最小必要字段、先保护协作再讨论实现。支持上下全屏切换,适合培训演示。
- 目标:降低跨团队沟通损耗,减少“今天能跑、明天炸掉”的发布事故。
- 路径:概念澄清 -> REST 实战 -> GraphQL 实战 -> 平台化治理。
- 输出:可复用的讲义结构、代码示例、流程图与演练题。
什么是契约测试:先确认“说话格式”
契约测试不判断系统功能是否完整,它只验证 Consumer 和 Provider 是否仍然“听得懂彼此”。
🍔 外卖类比
你点单就是 Consumer,请求格式就是“点单协议”;汉堡店是 Provider,必须按协议返回。若字段名从 status 改成 order_status,Provider 验证会立刻见红。
拆分验证两半
消费者先在 Mock Provider 上定义期望并产出契约 JSON;提供者再拿这份契约跑真实接口验证,实现“离线协同、在线拦截”。
单元 / E2E / 契约:职责边界表
契约测试关注“协议稳定”,不替代单测,也不替代端到端。
| 测试类型 | 核心回答 | 优点 | 典型成本 | 最适用场景 |
|---|---|---|---|---|
| 单元测试 | 这段业务逻辑本身是否正确? | 快、定位精准、可覆盖边界值 | 无法发现跨服务协议问题 | 金额计算、规则引擎、校验逻辑 |
| E2E 测试 | 真实系统串联后是否可用? | 最贴近真实用户链路 | 慢、脆弱、环境依赖重 | 下单、支付、退款等关键路径冒烟 |
| 契约测试 | 两边 API 约定是否兼容? | 联调前就可发现破坏性变更 | 不覆盖复杂业务正确性 | 前端-BFF、BFF-微服务接口协作 |
CDCT 核心理念:前端是“甲方”
CDCT(Consumer-Driven Contract Testing)强调:消费者只声明“真正需要的字段”,推动提供者实现最小可用接口。
消费者驱动
不是 Provider 先拍脑袋写全量字段,而是 Consumer 先定义最小必要数据,减少过度耦合。
Postel 法则
发送严格、接收宽容。请求格式约束清晰,响应解析不要依赖无关字段。
BFF 双角色
BFF 对上游前端是 Provider,对下游后端是 Consumer。每层都要有自己独立契约。
避坑指南:三个思维转变
这三条是契约实践成败分水岭,建议在团队代码评审中设为硬性检查项。
- 反模式一:把契约测试当集成测试。契约只管结构和类型,不测复杂业务规则。
- 反模式二:Consumer 和 Provider 各玩各的。契约必须围绕消费者真实使用路径协作演进。
- 反模式三:把业务边界值塞进契约。例如“负数金额触发哪条业务错误”应放到 Provider 单测。
- 关键动作:全部使用 Matchers 关注 Shape,不要硬编码具体内容值。
例子 1:职责边界
错误:在 Pact 里断言“余额不足”文案与优惠规则。
正确:只断言 400 响应包含 errorCode、message 字段;余额规则放 Provider 单测。
例子 2:避免硬编码
错误:固定断言 {"id":12345,"name":"Tom"}。
正确:使用 MatchersV3.integer()、MatchersV3.string(),关注类型与结构,不绑定具体值。
例子 3:交互要独立
错误:先调 /login 取 token,再链式调 /profile。
正确:每个 Interaction 独立;通过 provider state(如已鉴权用户)准备上下文。
阶段小测:字段删改是否会拦截?
场景:前端已不再使用 user_avatar,BFF 删除该字段后会不会被契约阻断部署?
点击问题查看答案(可用于现场互动)
A:不会。因为消费者已更新契约并上传到 Broker,新契约不再要求该字段。Provider 拉取最新契约验证自然通过。
阶段二:BFF -> 后端 REST Pact(可直接讲解)
骨架流程:Mock Provider -> 定义 Interaction -> 执行真实客户端请求 -> 生成 Pact JSON。
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const provider = new PactV3({ consumer: 'BFFService', provider: 'BackendUserService' });
provider
.uponReceiving('a request to get user profile')
.withRequest({ method: 'GET', path: '/users/10' })
.willRespondWith({
status: 200,
body: {
id: MatchersV3.integer(10),
username: MatchersV3.string('SampleUser')
}
});
await provider.executeTest(async (mockserver) => {
// 调用真实 API Client
});
- 关键词:PactV3、MatchersV3、executeTest。
- 实践结论:不要测业务边界值,只测接口交互结构。
阶段二案例:同事想测 -99999 ID,该不该?
这是最常见误区:把契约测试当“业务规则验证场”。
错误做法
在 Pact 里写死负数 ID,希望触发 USER_NOT_FOUND 之类业务错误,导致契约脆弱、维护负担激增。
正确做法
只保留一个 4xx 错误结构契约,例如 {"errorCode": string}。边界值交给 Provider 单元测试覆盖。
| 测试关注点 | 契约测试 | Provider 单元测试 |
|---|---|---|
| 错误 JSON 是否有 errorCode | ✅ 关注 | 可选 |
| -99999 是否触发 USER_NOT_FOUND | ❌ 不关注 | ✅ 必须覆盖 |
阶段三:前端 -> BFF GraphQL Pact
GraphQL 痛点:统一入口 /graphql,请求体是 Query 字符串,响应字段强依赖前端选择集。
import { PactV3, GraphQLInteraction, MatchersV3 } from '@pact-foundation/pact';
const graphqlCase = new GraphQLInteraction()
.withQuery(queryUser)
.withVariables({ id: '123' })
.withOperation('GetUserProfile')
.willRespondWith({
status: 200,
body: {
data: {
user: {
id: MatchersV3.string('123')
}
}
}
});
- 关键字必须理解:GraphQLInteraction、withQuery、withVariables。
- BFF 双身份:向上做 Provider、向下做 Consumer,各自契约独立演进。
- 治理建议:每个前端页面只声明自身真正消费字段,拒绝“预留一堆未来字段”。
阶段三思考:BFF 做 Provider 验证时怎么隔离下游?
当我们验证“前端 <-> BFF”契约时,BFF 对后端请求必须 mock,避免跨层耦合污染测试信号。
- 上游 Consumer 契约失败,说明 BFF 对前端承诺未满足。
- 下游 Consumer 契约失败,说明 BFF 调用后端的约定失真。
- 术语提醒:BFF 对前端是 Provider,但对下游服务是上游 Consumer。
阶段四:Broker + CI/CD 交通枢纽
Broker 负责版本矩阵与可视化依赖,流水线负责“未兼容就不准上线”。
# Consumer 发布契约
pact-broker publish ./pacts --consumer-app-version=$GIT_SHA --branch=main
# Provider 拉取并验证
Verifier --provider-base-url=http://localhost:8080 --broker-url=$PACT_BROKER
# 发布闸门
pact-broker can-i-deploy --pacticipant web-bff --version $GIT_SHA --to-environment production
契约毕业场景题:大促前删字段
场景:后端删掉 last_login_ip,前端契约仍预期该字段。
点击问题查看答案
提供者验证会先失败;最终执行 can-i-deploy 时,不兼容版本会被拦截。结论:消费者不能“偷加字段不沟通”。
课堂演练(10 分钟)
请小组把“优惠券查询链路”拆成两份契约:前端-BFF 与 BFF-优惠券服务,并写出各自的验证和发布卡点。
补充篇:Provider 验证原理 + Code Review 要点
让团队不仅会“写 Pact”,还能在审查中识别坏味道。
Provider 验证 5 步
- 从 Broker 拉取契约 JSON。
- 按 state 准备测试上下文。
- 发送契约请求到真实 Controller。
- 按 Matcher 规则比对响应。
- 回传验证结果到 Broker。
Code Review 红线
- 使用
term()时 generate 示例必须匹配正则。 - 测试对象应是项目真实 API Client,而不是临时 axios 片段。
- 优先 PactV3,避免旧 API 生命周期写法带来的接入复杂度。
Q&A 进阶:前端悄悄加字段会怎样?
这个问题最能体现契约“保护生产”的价值:不兼容需求不会被悄悄上线。
A:BFF 验证会先失败,Broker 记录不兼容。当前端执行 can-i-deploy 时会被拒绝,最终防止“前端先发、后端未就绪”事故。
A:短期多一步契约校验,长期显著减少联调返工和生产事故,是把风险前置而不是增加流程噪音。
收尾:把契约精神固化为团队机制
建议将本页作为 onboarding 标准材料,并与模板仓库、CI 脚本、代码评审清单联动。
流程约束
每个跨服务接口必须有 Consumer 契约与 Provider 验证记录,禁止“口头字段约定”。
工程约束
CI 必须含 can-i-deploy;上线前必须具备可追溯 Broker 版本矩阵。
文化约束
坚持“只断言真实需求字段”,通过契约减少组织沟通摩擦,提升跨团队协作效率。