接口的破坏往往发生在两个团队各自都"测试全绿"的时候:后端改了字段名,自己的单元测试照样通过;前端按老字段写了 mock,自己跑得挺好。两边都没错,错的是它们之间那份口头约定没人验证。集成环境一联调,才发现 userName 变成了 username。
契约测试把这份约定变成可执行的文件:消费方声明自己要什么,提供方拿这份声明跑一遍自己的实现,两边都对得上才放行。
1. 接口约定为什么容易破
| 破裂方式 | 谁先发现 | 代价 |
|---|---|---|
| 字段改名 | 联调或线上 | 前端解析出 null |
类型变化(int → string) |
前端反序列化报错 | 页面白屏 |
| 字段被删 | 用到该字段的页面 | 局部功能失效 |
| 必填变可选 / 可选变必填 | 空指针或校验异常 | 偶发故障 |
| 状态码变化(200 → 202) | 前端走了 else 分支 | 业务静默失败 |
| 语义悄悄变(单位、分页基准) | 数据不对但没人报错 | 最难排查 |
这些问题的共同点:双方的测试都不会失败——单测只看自己这侧的实现。
2. 契约测试与集成测试、E2E 的分工
| 层级 | 验证什么 | 依赖 | 反馈速度 |
|---|---|---|---|
| 单元测试 | 一个类/方法的逻辑 | 无 | 秒级 |
| 契约测试 | 双方对接口形状与关键语义的一致 | 无真实对端 | 秒级到分钟级 |
| 集成测试 | 本服务与真实中间件能否协作 | 数据库、MQ | 分钟级 |
| E2E | 整条链路业务能否走通 | 全部服务 | 十几分钟,易碎 |
契约测试的价值就在"无真实对端"这一格:它把联动验证从"必须两套环境同时在"降级成"各自 CI 里跑一份文件"。它不替代集成测试,因为契约不验证 SQL、不验证中间件配置;也不替代 E2E,因为契约不验证跨服务的编排。
一条常见的组合是:契约测试守接口形状,集成测试守本服务能力,E2E 只保留 3–5 条主干流程。
3. 消费者驱动契约的流程
消费者驱动(CDC)的意思是由消费方定义期望,而不是由提供方写一份"我觉得你会这么用"的文档。流程四步:
- 消费方写测试,声明"我调用
GET /api/orders/{id},期望返回里必须有id:number、status:string、amount:number"; - 测试跑通后生成契约文件(JSON),提交到契约仓库;
- 消费方在 CI 里用契约生成 stub,前端/下游服务对 stub 做测试,不必等后端;
- 提供方 CI 拉取契约,对着自己的真实实现回放请求,断言响应满足契约。
# 提供方验证:失败即构建失败,不允许"先合再说"
mvn -B verify -DcontractVerifier.enabled=true
流程里最容易被跳过的是第 3 步:没有 stub,前端只能等后端环境,契约退化成一份文档。
4. Spring Cloud Contract 的核心用法
4.1 Groovy 契约
// src/test/resources/contracts/order/shouldReturnOrder.groovy
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description("查询订单返回契约")
request {
method GET()
urlPath('/api/orders/1001')
headers { header('Accept', 'application/json') }
}
response {
status OK()
headers { contentType(applicationJson()) }
body([
id : $(consumer(1001), producer(1001)),
status : $(consumer(regex('CREATED|PAID|REFUNDED')), producer('PAID')),
amount : $(consumer(anyNumber()), producer(99.00)),
userName: $(consumer(regex(nonBlank())), producer('yg'))
])
}
}
$(consumer(...), producer(...)) 是契约里最关键的一对:consumer 侧写它关心的形状(正则、类型、范围),producer 侧写一个能生成真实响应的具体值。草稿模式下 provider 侧只能举例,不能断言具体数值,否则实现一变就要改契约。
4.2 YAML 契约(等价写法)
description: 查询订单返回契约
request:
method: GET
url: /api/orders/1001
response:
status: 200
headers:
Content-Type: application/json
body:
id: 1001
status: PAID
amount: 99.00
Groovy 适合需要 regex、anyNumber 这类动态匹配的场景,YAML 更适合给非 Java 团队看。同一个项目里可以混用。
4.3 插件配置
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>4.1.x</version> <!-- 随 Spring Cloud 版本核对 -->
<extensions>true</extensions>
<configuration>
<baseClassForTests>com.example.ContractBase</baseClassForTests>
<testFramework>JUNIT5</testFramework>
</configuration>
</plugin>
baseClassForTests 指向的基类要负责把服务起在随机端口,并提供可被契约引用的数据:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class ContractBase {
@LocalServerPort int port;
@MockBean OrderQueryService queryService; // 只桩掉数据源,不桩掉 MVC 层
}
只桩数据层,不桩 Controller。把 Controller 也桩掉,契约验证就变成了"验证桩",跟真实实现对不上。
4.4 stub jar
spring-cloud-contract-maven-plugin 在 install 阶段会产出 -stubs.jar,消费方直接引:
<dependency>
<groupId>com.example</groupId>
<artifactId>order-service</artifactId>
<version>1.3.0</version>
<classifier>stubs</classifier>
<scope>test</scope>
</dependency>
@AutoConfigureStubRunner(ids = "com.example:order-service:+:stubs:6565",
stubsMode = StubRunnerProperties.StubsMode.LOCAL)
@SpringBootTest
class OrderClientTest { /* 直接调 http://localhost:6565/api/orders/1001 */ }
+ 表示取最新版本;生产做法是钉死版本号,否则上游一次发布就可能让下游测试莫名变红。
5. Pact 的流程对照
| 环节 | Spring Cloud Contract | Pact |
|---|---|---|
| 契约语言 | Groovy / YAML | 代码内 DSL(Java、JS、Go…) |
| 消费者侧 | 手写契约 + stub jar | 消费方测试运行时生成 pact.json |
| 提供方验证 | 插件生成测试类回放请求 | pact-provider-verifier 或 JUnit 5 扩展 |
| 契约存放 | Git 仓库或 Maven 仓库 | Pact Broker(推荐) |
| 版本协商 | Maven 版本 | Broker 的 tag + 环境矩阵 |
| 适用栈 | Spring 生态内最顺 | 跨语言、跨 Spring 栈 |
Pact 的核心差别是契约由测试运行产生,避免手写契约与客户实际调用不一致;代价是必须有 Broker 来做"哪些版本已兼容"的判断,否则 can-i-deploy 这类门禁无从谈起。
6. 契约该覆盖什么、不该覆盖什么
| 该覆盖 | 不该覆盖 |
|---|---|
| 字段名与大小写 | 业务计算细节(积分怎么算) |
| 字段类型(number/string/bool) | 内部实现(是否分表、用了哪个 ORM) |
| 必填与非空 | 时间戳的具体秒数 |
| 枚举取值范围 | 数据总条数 |
| 状态码 | 排序算法的具体结果 |
关键 header(Content-Type、分页头) |
日志内容、DB 写入细节 |
| 错误响应的形状 | 重试次数、熔断阈值 |
一个实用判断:契约只描述"我能看见的接口表面"。如果你需要在契约里断言业务规则,那应该写在提供方的单测里。
7. 契约版本管理与 CI 位置
| 做法 | 优点 | 风险 |
|---|---|---|
| 契约随服务仓库 | 改动 Reviewer 一眼看到 | 多消费方时契约散落 |
| 独立契约仓库 | 集中、可 tag | 与实现分离,容易漂移 |
| Pact Broker | 有兼容矩阵 | 多一个组件要运维 |
CI 里的位置建议固定为:提供方的 verify 阶段跑契约验证,消费方的 test 阶段跑 stub 测试,两者都是门禁,都不允许 continue-on-error。
# 提供方流水线片段
- name: contract-verify
run: mvn -B verify
- name: publish-stubs
if: github.ref == 'refs/heads/main'
run: mvn -B deploy -DskipTests
只有 main 发布 stub,feature 分支不发,避免下游拉到半成品版本。
8. 只靠 OpenAPI schema 校验够不够
| 校验方式 | 能发现 | 不能发现 |
|---|---|---|
| OpenAPI schema 校验响应 | 缺字段、类型不符 | 消费方是否真的读这个字段 |
| 契约测试 | 同上 + 消费方期望的字段确实存在 | 跨服务编排 |
| 集成测试 | 真实中间件问题 | 接口形状变动 |
OpenAPI 校验是必要但不充分的:它保证响应合法,却不保证合法响应里包含消费方要的字段。字段从必填降级成可选、schema 仍能通过,消费方却会拿到 null。所以契约测试不能省,两者叠加才闭环。
9. 排错:七种契约测试的常见故障
| 现象 | 原因 | 处理 |
|---|---|---|
| 提供方验证全绿,联调仍报错 | 契约写的 producer 值与实现不一致 | 检查是否用了 consumer() 匹配而 producer 侧写死 |
| stub 返回和真实响应不同 | stub jar 版本滞后 | 钉死版本或改 + 为具体版本 |
| 契约测试要起数据库 | 基类里装配了完整上下文 | 只桩数据层,或改用 @WebMvcTest |
| 一改 Controller 全部契约红 | 契约在断言内部实现 | 把业务断言移出契约 |
| 契约测试跑得比集成测试还慢 | 每个契约都重起一次上下文 | 复用 baseClassForTests 的上下文 |
| 下游总在凌晨变红 | 用了 + 拉最新 stub |
钉死版本 |
| 契约文件没人维护 | 没有门禁 | 把 verify 设为必过门槛 |
10. 边界:什么情况该用别的办法
| 场景 | 更适合 | 理由 |
|---|---|---|
| 只内部调用的私有方法 | 单元测试 | 没有跨团队约定 |
| 第三方 API | 录制回放(VCR)或打桩 | 你无法要求对方验证契约 |
| 跨服务事务编排 | E2E + 对各服务单独契约 | 编排属于集成层 |
| 前端纯展示组件 | 组件测试 | 不涉及接口 |
| 消息队列的消息体 | 消息契约(Pact 支持) | 同样需要双方约定 |
| 数据库 schema 演进 | 迁移脚本 + 集成测试 | 不属于接口层 |
一句话收口:契约测试守的是两条边界的对齐,不是任何一侧的内部正确性。
11. 自测
- 后端改字段名时,为什么双方的单测都不会失败?
- 消费者驱动的"驱动"具体指哪一方决定契约内容?
$(consumer(...), producer(...))里 producer 侧为什么不能写成严格断言?baseClassForTests的基类该桩掉哪些层、不该桩掉哪一层?- 已有 OpenAPI schema 校验时,契约测试还需覆盖什么?
- 契约测试在 CI 里应该放在哪个阶段,能不能设成非阻塞?