2661 字
约 8 分钟
17
S16-X05 · 契约测试:前后端与微服务间的接口约定

接口的破坏往往发生在两个团队各自都"测试全绿"的时候:后端改了字段名,自己的单元测试照样通过;前端按老字段写了 mock,自己跑得挺好。两边都没错,错的是它们之间那份口头约定没人验证。集成环境一联调,才发现 userName 变成了 username。

契约测试把这份约定变成可执行的文件:消费方声明自己要什么,提供方拿这份声明跑一遍自己的实现,两边都对得上才放行。


1. 接口约定为什么容易破

破裂方式 谁先发现 代价
字段改名 联调或线上 前端解析出 null
类型变化(int → string) 前端反序列化报错 页面白屏
字段被删 用到该字段的页面 局部功能失效
必填变可选 / 可选变必填 空指针或校验异常 偶发故障
状态码变化(200 → 202) 前端走了 else 分支 业务静默失败
语义悄悄变(单位、分页基准) 数据不对但没人报错 最难排查

这些问题的共同点:双方的测试都不会失败——单测只看自己这侧的实现。


2. 契约测试与集成测试、E2E 的分工

层级 验证什么 依赖 反馈速度
单元测试 一个类/方法的逻辑 无 秒级
契约测试 双方对接口形状与关键语义的一致 无真实对端 秒级到分钟级
集成测试 本服务与真实中间件能否协作 数据库、MQ 分钟级
E2E 整条链路业务能否走通 全部服务 十几分钟,易碎

契约测试的价值就在"无真实对端"这一格:它把联动验证从"必须两套环境同时在"降级成"各自 CI 里跑一份文件"。它不替代集成测试,因为契约不验证 SQL、不验证中间件配置;也不替代 E2E,因为契约不验证跨服务的编排。

一条常见的组合是:契约测试守接口形状,集成测试守本服务能力,E2E 只保留 3–5 条主干流程。


3. 消费者驱动契约的流程

消费者驱动(CDC)的意思是由消费方定义期望,而不是由提供方写一份"我觉得你会这么用"的文档。流程四步:

  1. 消费方写测试,声明"我调用 GET /api/orders/{id},期望返回里必须有 id:number、status:string、amount:number";
  2. 测试跑通后生成契约文件(JSON),提交到契约仓库;
  3. 消费方在 CI 里用契约生成 stub,前端/下游服务对 stub 做测试,不必等后端;
  4. 提供方 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. 自测

  1. 后端改字段名时,为什么双方的单测都不会失败?
  2. 消费者驱动的"驱动"具体指哪一方决定契约内容?
  3. $(consumer(...), producer(...)) 里 producer 侧为什么不能写成严格断言?
  4. baseClassForTests 的基类该桩掉哪些层、不该桩掉哪一层?
  5. 已有 OpenAPI schema 校验时,契约测试还需覆盖什么?
  6. 契约测试在 CI 里应该放在哪个阶段,能不能设成非阻塞?
S16-X05 · 契约测试:前后端与微服务间的接口约定
https://www.clxhxhhr.top/posts/5731/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。