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/版本管理能力,契约文件只能靠聊天传递——这时先把接口版本和发布流程理清,再上契约。

关联