53.5 契约测试
一句话
契约测试把“消费者需要什么”固化成一份可执行契约,让生产者在改坏接口的那一刻失败,而不是等联调;它验证的是接口兼容性,不是业务正确性。
先破除五个误区
| 误区 | 现实 |
|---|---|
| “有 OpenAPI 就够了” | Schema 描述“可以返回什么”,不记录“消费者实际用了哪些字段”。删掉一个没人用的字段,Schema 合法,消费者却挂了 |
| “契约测试就是集成测试” | 契约测试用 provider 的 handler 回放请求,不启动真实 DB/MQ;它只验证接口形状与语义兼容 |
| “Provider 自己写契约” | 那就是 Provider 的自我想象。契约必须消费者驱动:消费者说“我需要 total”,Provider 才有义务保留 |
| “字段只增不减就没事” | 加可选字段兼容;改类型、改名、删字段、改错误码格式都是破坏性变更 |
| “契约通过就能上线” | 还要确认“哪些消费者版本还在生产”才能发布,这一步是 can-i-deploy,不是契约测试本身 |
消费者驱动的闭环
消费者测试 ──生成──▶ 契约文件 ──上传──▶ Broker
│
生产者 CI ◀──拉取并回放──────────────────┘
│
└──通过──▶ can-i-deploy(查生产中的消费者版本)──▶ 发布
| 角色 | 动作 | 失败意味着 |
|---|---|---|
| 消费者 | 写期望:请求 + 必需字段 + 状态码 | 自己的解析逻辑与契约不一致 |
| 契约仓库 | 存版本化契约,按 consumer/provider/环境打标签 | 不知道谁依赖谁 |
| 生产者 | 用真实 handler 回放契约 | 改坏了正在被使用的接口 |
| 发布门禁 | 查生产中的消费者版本是否都被新 Provider 兼容 | 上线会打断老客户端 |
哪些变更兼容,哪些是破坏
| 变更 | 兼容? | 处理 |
|---|---|---|
| 新增可选字段 | ✅ | 消费者必须忽略未知字段 |
| 新增必填字段 | ❌ | 走新版本/新端点,或给默认值 |
| 删除字段 | ❌ | 先确认无消费者使用,再按版本下线 |
| 字段改名 | ❌ | 等价于“删旧 + 加新”,需双写过渡 |
| 类型变化(int → string) | ❌ | 新字段并行,旧字段保留一个版本 |
| 枚举新增值 | ⚠️ | 消费者能容忍未知值才兼容,否则算破坏 |
| 错误码/错误体结构变化 | ❌ | 消费者通常按 code 分支,最容易漏 |
| 仅实现变化、响应不变 | ✅ | 契约测试应保持绿 |
🔑 兼容性判断不看“Provider 觉得合不合理”,看“已发布的消费者会不会解析失败”。语义化版本的规则见 51.3 语义化版本与变更日志。
数字:联调矩阵 vs 契约矩阵
20 个服务,两两可能调用:
| 方式 | 需要验证的组合 | 环境成本 |
|---|---|---|
| 全量联调 | 最多 20×19/2 = 190 对 | 每对要一套可用环境,指数级 |
| 契约测试 | 只覆盖真实消费关系的约 30 对 | 每对一份契约,线性增长 |
契约不会消灭集成测试(真实依赖的验证仍见 53.3 集成测试),它把“跨团队接口兼容”从联调阶段提前到各自 CI(流水线编排见 52.4 流水线设计与耗时优化),让 190 个组合里真正存在的 30 个先被挡住。示例数量按实际调用关系估算,不是定理。
最小可运行实现
为了可读,下面的 contract.json 是手写的;真实 Pact 流程里它由消费者测试自动生成,Provider 侧只认 Broker 上的版本。
contract.json(消费者拥有):
{
"consumer": "web",
"provider": "order-api",
"interactions": [
{
"description": "查询已支付订单",
"request": { "method": "GET", "path": "/orders/42" },
"response": {
"status": 200,
"body": { "id": 42, "status": "paid", "total": 1999 }
}
}
]
}
消费者侧只关心“我能不能解析”;Provider 侧用真实 handler 回放,并按子集比对(多返回字段不算破坏):
package contract
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
)
type Interaction struct {
Description string `json:"description"`
Request struct {
Method string `json:"method"`
Path string `json:"path"`
} `json:"request"`
Response struct {
Status int `json:"status"`
Body map[string]any `json:"body"`
} `json:"response"`
}
type Contract struct {
Consumer string `json:"consumer"`
Provider string `json:"provider"`
Interactions []Interaction `json:"interactions"`
}
func load(path string) (Contract, error) {
var c Contract
b, err := os.ReadFile(path)
if err != nil {
return c, err
}
return c, json.Unmarshal(b, &c)
}
// MatchSubset:Provider 多返回字段是兼容的;少字段/类型变才是破坏。
func MatchSubset(want, got any, path string) error {
switch w := want.(type) {
case map[string]any:
g, ok := got.(map[string]any)
if !ok {
return fmt.Errorf("%s: 类型变了: want object, got %T", path, got)
}
for k, v := range w {
gv, ok := g[k]
if !ok {
return fmt.Errorf("%s.%s: 缺少字段", path, k)
}
if err := MatchSubset(v, gv, path+"."+k); err != nil {
return err
}
}
return nil
default:
if fmt.Sprint(want) != fmt.Sprint(got) {
return fmt.Errorf("%s: want %v, got %v", path, want, got)
}
return nil
}
}
func TestConsumerCanParse(t *testing.T) {
c, err := load("contract.json")
if err != nil {
t.Fatal(err)
}
type Order struct {
ID int `json:"id"`
Status string `json:"status"`
Total int `json:"total"`
}
body, _ := json.Marshal(c.Interactions[0].Response.Body)
var o Order
if err := json.Unmarshal(body, &o); err != nil {
t.Fatalf("消费者解析失败: %v", err)
}
if o.ID != 42 || o.Total != 1999 {
t.Fatalf("got %+v", o)
}
}
func TestProviderHonorsContract(t *testing.T) {
c, err := load("contract.json")
if err != nil {
t.Fatal(err)
}
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/orders/42" {
http.NotFound(w, r)
return
}
fmt.Fprint(w, `{"id":42,"status":"paid","total":1999}`)
})
srv := httptest.NewServer(h)
defer srv.Close()
for _, in := range c.Interactions {
req, _ := http.NewRequest(in.Request.Method, srv.URL+in.Request.Path, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != in.Response.Status {
t.Fatalf("%s: status=%d, want %d", in.Description, resp.StatusCode, in.Response.Status)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("%s: 响应不是 JSON: %v", in.Description, err)
}
if err := MatchSubset(in.Response.Body, got, in.Description); err != nil {
t.Fatalf("契约破坏: %v", err)
}
}
}
$ go test -v ./...
--- PASS: TestConsumerCanParse (0.00s)
--- PASS: TestProviderHonorsContract (0.00s)
PASS
# 把 Provider 的 total 改名为 total_cents 后
--- FAIL: TestProviderHonorsContract (0.00s)
contract_test.go: 契约破坏: 查询已支付订单.total: 缺少字段
FAIL
契约测试在 Provider 的 CI 里失败,而不是在联调群里吵架,这就是它的全部价值。
什么时候用,什么时候别用
| 场景 | 契约测试? | 用什么 |
|---|---|---|
| 跨团队 HTTP/gRPC 接口 | ✅ | Pact / Spring Cloud Contract / Buf(protobuf breaking) |
| 移动端 / 第三方开放 API | ✅ | 契约 + 版本存活期(老版本必须继续可用) |
| 单体内两个函数 | ❌ | 单元测试(见 53.2 单元测试) |
| 完整业务流程 | ❌ | 端到端测试(见 53.4 端到端测试) |
| 消息队列事件 Schema | ✅ | Schema Registry + 兼容性策略(BACKWARD/FORWARD/FULL) |
| 数据库表结构 | ❌ | 迁移测试 + Expand-Contract |
生产落地:指名 → 可抄 → 可验 → 可退
指名:Pact(消费者驱动契约事实标准,配 Pact Broker);gRPC/protobuf 用 Buf 的 breaking-change 检测;OpenAPI 用 Schemathesis 做属性测试。契约的“消费者侧 stub”可以用测试替身(见 53.6 测试替身)。
可抄(Pact 流程的命令形态,本机未安装验证):
# 消费者测试生成 pact 文件后发布
pact-broker publish ./pacts \
--consumer-app-version="$GIT_SHA" \
--branch="$GIT_BRANCH" \
--broker-base-url="$PACT_BROKER_URL"
# 生产者验证前,先确认生产中的消费者版本都可兼容
pact-broker can-i-deploy \
--pacticipant order-api --version "$GIT_SHA" \
--to-environment production
可验:本机跑上面的 Go 用例,看到 PASS;把 Provider 的 total 改名,看到“缺少字段”的失败。接入 Pact 后,还要验两件事:Broker 上能看到 consumer/provider 版本矩阵;can-i-deploy 在生产消费者版本过旧时返回非 0。
可退:
- Provider 必须临时改接口:先发兼容版本(新旧字段并存),等消费者升级完再删旧字段。
- 契约本身写错:回滚契约文件版本,重新发布;不要直接删 Broker 记录。
- 老客户端无法升级:保留旧版本端点,按版本存活期下线;契约里显式标注 deprecation。
反模式
| 反模式 | 后果 |
|---|---|
| Provider 单方面写契约 | 测的是自我想象,消费者一接就炸 |
| 全量 JSON 精确比对 | 加一个可选字段就红,契约变成“响应快照” |
| 契约里写业务状态/DB 数据 | 契约测试变成集成测试,慢且脆 |
| 没有 Broker/版本标签 | 不知道谁依赖谁,can-i-deploy 无从谈起 |
| 契约只在夜间跑 | 破坏性变更到第二天才发现 |
| 忽略存量消费者版本 | 新 Provider 上线,老 App 直接白屏 |
| 用契约测试替代集成测试 | 接口形状对了,SQL 照样错 |
自检
Q1 团队有 OpenAPI 文档,为什么还需要契约测试?
▸ 答案:OpenAPI 是“接口能返回什么”的描述,契约是“消费者实际依赖什么”的可执行断言。Provider 删掉一个 OpenAPI 里可选的字段,Schema 仍然合法,但消费者解析会失败;契约测试会在 Provider 的 CI 里报“缺少字段”。另外 OpenAPI 不会告诉你“生产里还有哪些消费者版本”,这部分要靠 Broker 的版本矩阵与 can-i-deploy。
Q2 为什么契约比对要用“子集匹配”,而不是整段 JSON 相等?
▸ 答案:因为兼容性的定义是“消费者不挂”,不是“响应字节完全一致”。Provider 新增可选字段、调整字段顺序、增加 header,都不应让消费者失败;整段相等会把契约变成响应快照,任何无害变更都红,团队很快就开始改契约迁就实现。子集匹配只断言消费者用到的字段存在且类型/值正确,给 Provider 留出演进空间。
Q3 20 个服务、190 个可能的调用对,契约测试能全测吗?
▸ 答案:不能,也不需要。契约只覆盖真实存在的消费关系(示例里约 30 对),没有调用的服务对不需要契约。它的收益是把这 30 对的兼容性验证从“联调环境里碰”提前到“各自 CI 里跑”,而不是消灭集成测试。剩下的业务链路仍要靠集成/端到端测试。
Q4 契约测试通过,为什么还不能直接上线?
▸ 答案:因为契约只证明“当前这份契约的消费者”兼容,不证明“生产里所有消费者版本”兼容。上线前要查 Broker:生产环境里还有哪些 consumer 版本、它们对应的契约是否都被新 Provider 满足(can-i-deploy)。如果有老版本 App 还在用旧字段,直接上线会打断它们。
Q5 什么情况下不该引入契约测试?
▸ 答案:三种:① 只有一个消费者且与 Provider 同仓同团队部署,单元/集成测试足够;② 接口还在高频试错、没有稳定消费者,契约会变成负担;③ 团队没有 Broker/版本管理能力,契约文件只能靠聊天传递——这时先把接口版本和发布流程理清,再上契约。