2785 字
约 9 分钟
16
S16-X17 · 契约测试的落地:Pact、CDC 与兼容性门禁
2024-12-31
2026-11-09

接口不兼容的典型现场:提供者改了某字段类型、删了一个看起来没人用的字段,本地全绿,直到联调页面白屏才发现消费者早就依赖它。根因是双方对同一份接口的假设从没被机器校验过。

契约测试做的就是这件事:把"消费者用到了什么"写成机读文件,让提供者的每次改动都在流水线里被问一句"你还满足下游吗"。


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. 自测

  1. 契约测试与集成测试各自回答什么问题?
  2. CDC 与提供者契约的方向差别在哪?
  3. 契约文件里出现"响应全部字段",会带来什么后果?
  4. @State 钩子该不该写断言?它该做什么?
  5. pending pacts 与 WIP pacts 分别缓冲了哪种失败?
  6. 哪些问题契约测试天然覆盖不到?
S16-X17 · 契约测试的落地:Pact、CDC 与兼容性门禁
https://www.clxhxhhr.top/posts/6001/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。