接口不兼容的典型现场:提供者改了某字段类型、删了一个看起来没人用的字段,本地全绿,直到联调页面白屏才发现消费者早就依赖它。根因是双方对同一份接口的假设从没被机器校验过。
契约测试做的就是这件事:把"消费者用到了什么"写成机读文件,让提供者的每次改动都在流水线里被问一句"你还满足下游吗"。
1. 契约测试解决什么,不解决什么
| 它能回答 | 它不回答 |
|---|---|
| 消费者依赖的字段是否还在、类型是否变 | 字段的值在业务上是否算对 |
| 提供者是否满足所有已知消费者的期望 | 两个服务串起来的时序是否正确 |
这次改动能否安全上线(can-i-deploy) |
性能是否达标 |
| 哪个消费者会因为这次改动受影响 | 数据库、消息中间件的真实行为 |
定位一句话:契约测试是接口假设的一致性校验,不是集成测试的替代品。
2. 两种契约,方向相反
| 维度 | 消费者驱动契约(CDC) | 提供者契约 |
|---|---|---|
| 谁写 | 消费者 | 提供者 |
| 产物 | 契约文件(*.json) |
OpenAPI / JSON Schema |
| 保证 | 提供者满足实际使用方 | 提供者声明了自己的形状 |
| 风险 | 需要消费者参与、中枢管理 | 声明可能与真实使用不符、易过期 |
| 工具 | Pact、Spring Cloud Contract | springdoc / Swagger、JSON Schema |
两条路线可并存:CDC 管"有人在用的部分",OAS 管对外文档。只做 OAS 的典型盲区是提供者删掉文档里没写的字段,而那字段恰好是某个消费者唯一依赖的。
3. Pact 的四个环节
| 环节 | 在哪跑 | 产物 |
|---|---|---|
| 消费者测试 | 消费者仓库 | 生成 pacts/<consumer>-<provider>.json |
| 发布契约 | 消费者流水线 | 契约上传到 Pact Broker,带版本与分支 |
| 提供者验证 | 提供者仓库 | 回放契约,产出验证结果并回传 broker |
| 上线门禁 | 部署流水线 | can-i-deploy 查询兼容矩阵 |
消费者侧的最小写法:
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "order-service", port = "9999")
class PricingConsumerPactTest {
@Pact(consumer = "pricing-service")
public RequestResponsePact quotePact(PactDslWithProvider builder) {
return builder
.given("订单 42 存在且已支付")
.uponReceiving("查询订单金额")
.path("/api/orders/42/amount")
.method("GET")
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body(newJsonBody(o -> o
.stringType("currency", "CNY")
.numberType("amount", 100.00)).build())
.toPact();
}
@Test
void should_read_amount(MockServer mockServer) {
OrderClient client = new OrderClient(mockServer.getUrl());
assertThat(client.amount(42L).currency()).isEqualTo("CNY");
}
}
关键点:消费者测试是起一个 mock 服务端,让真实客户端代码去打它——测解析逻辑,顺手记下请求形状。
4. 契约里该写什么
| 写什么 | 例子 | 理由 |
|---|---|---|
| 影响路由与语义的请求部分 | 路径、方法、关键 header | 提供者靠它匹配 |
| 响应中消费者实际用到的字段 | currency、amount |
这才是有使用方的契约 |
| 类型而非具体值 | stringType、numberType、like |
具体值会锁死提供者的数据 |
| 错误契约 | 401/404 的响应形状 | 客户端要处理这些分支 |
| 不该写什么 | 后果 |
|---|---|
| 响应全部字段 | 提供者加字段就全红,契约成了绊脚石 |
| 与消费者无关的字段 | 提供者无法内部重构 |
| 精确到小数位的值、时间戳 | 数据一改就红,测的是数据不是接口 |
生成的契约片段如下,matchingRules 正是它比手写断言强的地方:
{
"consumer": { "name": "pricing-service" },
"provider": { "name": "order-service" },
"interactions": [{
"description": "查询订单金额",
"request": { "method": "GET", "path": "/api/orders/42/amount" },
"response": {
"status": 200,
"body": { "currency": "CNY", "amount": 100.00 },
"matchingRules": {
"$.body.currency": { "matchers": [{ "match": "type" }] }
}
}
}]
}
自查:把每个字段问一遍"它消失时哪个消费者代码会坏",答不上来的删掉。
5. 提供者验证:三种状态
| 状态 | 含义 | 常见误用 |
|---|---|---|
@State / 状态钩子 |
让数据满足契约的 given 前提 |
状态名写成实现的私有细节,一重构就断 |
| 拉取方式 | @PactBroker 拉取,或 @PactFolder 读本地 |
本地文件夹在 CI 里等于没门禁 |
| 结果回传 | 写回 broker,标记 provider 版本 | 不回传,can-i-deploy 无据 |
@Provider("order-service")
@PactBroker(url = "https://broker.internal", authentication = @PactBrokerAuth(token = "${PACT_TOKEN}"))
@SpringBootTest(webEnvironment = RANDOM_PORT)
class OrderProviderPactTest {
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verify(PactVerificationContext ctx) {
ctx.verifyInteraction();
}
@State("订单 42 存在且已支付")
void orderExists() {
orderRepository.save(anOrder().withId(42L).withStatus(PAID).build());
}
}
状态钩子只干一件事:把数据准备到契约描述的前置条件,不写断言。状态名是双方共享词汇,要描述业务事实("订单已支付"),不是表结构。
6. 契约与 schema 校验的分工
| 校验方式 | 输入 | 回答的问题 | 何时跑 |
|---|---|---|---|
| 契约(Pact) | 消费者用到的字段 | 有人用的部分变了吗 | 消费者/提供者每次提交 |
| Schema(OAS/JSON Schema) | 提供者声明的形状 | 文档与实现一致吗 | 提供者每次提交 |
| 集成测试 | 双方真实运行 | 串起来能跑通吗 | 合并前后 |
| 冒烟/E2E | 全链路 | 关键路径可用吗 | 部署后 |
契约是消费者需要的,OAS 是提供者声明的;两者冲突时契约优先,因为它背后有真实代码在依赖。
7. 版本、分支与破坏性变更的缓冲
# 消费者侧:把生成的契约连同版本与分支一起发布
pact-broker publish ./pacts \
--consumer-app-version "$GIT_SHA" --branch main \
--broker-base-url https://broker.internal
# 部署前查询:这个应用版本能否与目标环境的提供者/消费者共存
pact-broker can-i-deploy \
--pacticipant pricing-service \
--version "$GIT_SHA" \
--to-environment production \
--broker-base-url https://broker.internal
| 概念 | 作用 | 配置要点 |
|---|---|---|
| 分支选择器 | 决定验证哪个消费者版本 | 用 main 与 prod,别用 * 全拉 |
pending pacts |
新契约首次失败不阻塞提供者 | 需要按消费者版本区分,否则会把真破坏当 pending |
WIP pacts |
消费者侧未完成的契约不阻塞提供者 | 有时间窗,过期自动转正 |
| 环境/版本标记 | 让 can-i-deploy 有据可查 |
部署流水线里回写部署记录 |
pending pacts 与 WIP pacts 的价值是把新增契约的暂时失败与真实破坏分开:新契约首次失败不该挡住提供者发布,但同一消费者版本上重复失败就转成正式失败。
别把 selector 配成"拉全部契约":提供者会被迫满足所有历史版本,最后人人都把这一步设成"允许失败"。
8. 契约测试覆盖不到的地方
| 类别 | 例子 | 该用什么 |
|---|---|---|
| 语义错误 | 金额单位从分变成元 | 业务测试、对账校验 |
| 时序/并发 | 先取消再支付、重复下单 | 集成测试、状态机测试 |
| 性能 | 延迟、吞吐 | 性能测试 |
| 多跳链路 | 网关鉴权、跨服务事务 | E2E 或少量集成测试 |
| 异步契约 | 消息的 key、分区、死信 | 消息契约工具 + broker 集成测试 |
异步尤其要注意:HTTP 契约只覆盖请求/响应,"事件发出去没人消费"要靠消息侧契约验证。只做一层 HTTP 契约就宣称端到端覆盖,是落地时最常见的自我安慰。
9. 与集成测试的分工
| 关注点 | 契约测试 | 集成测试 |
|---|---|---|
| 依赖的字段 | 全覆盖,逐字段 | 只覆盖测试里写的那几条 |
| 运行成本 | 低(仅 mock server) | 高(容器、网络) |
| 两侧仓库独立跑 | 能 | 通常不能 |
| 数据准备 | 状态钩子最小数据 | 完整数据环境 |
| 可读性 | 直指"消费者 X 的字段 Y 没了" | 要翻链路日志 |
组合原则:契约管接口形状与字段兼容,集成管本方与真实依赖的关键路径,E2E 只留少数冒烟。
10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 新增字段导致全红 | 契约断言了全部响应字段 | 只断言消费者用到的字段,用 numberType |
can-i-deploy 一直返回 unknown |
部署记录没回写、验证结果没上传 | 在流水线里加 record-deployment 与结果回传 |
| 状态钩子找不到数据 | 状态名与数据准备不匹配、事务未提交 | 在钩子里显式提交;核对 given 文本逐字一致 |
| 契约测试在 CI 里被跳过 | @PactBroker 地址或 token 缺失,用例被条件禁用 |
缺变量应失败,不能跳过 |
| 提供者验证用了生产数据 | 直连共享环境 | 用容器/内存库 + 状态钩子造数 |
| 消费者契约从不更新 | 没人跑消费者侧测试 | 生成与发布进流水线必经阶段 |
11. 边界:什么时候不值得上契约测试
| 场景 | 更合适 | 理由 |
|---|---|---|
| 单体内部调用 | 编译期接口 + 集成测试 | 同进程内契约不是主要风险 |
| 同团队同发布 | 集成测试 | 同步发布收益低 |
| 极不稳定的早期接口 | 先稳定形状再补 | 高频变动会变成频繁红 |
| 第三方外部 API | 按其文档做客户端测试 | 无法要求对方跑验证 |
契约测试的收益与"消费者数量 × 独立发布频率"正相关。 一个消费者、同一天发布的场景,维护成本可能高于它挡住的故障;五个以上消费者各自发版时,不上契约就等于拿联调环境当门禁。
12. 自测
- 契约测试与集成测试各自回答什么问题?
- CDC 与提供者契约的方向差别在哪?
- 契约文件里出现"响应全部字段",会带来什么后果?
@State钩子该不该写断言?它该做什么?pending pacts与WIP pacts分别缓冲了哪种失败?- 哪些问题契约测试天然覆盖不到?