《Go 语言编程实战》11.2 契约测试与 mock

解决「依赖别人的服务怎么测」:先用 testify/mock 把 TaskHub 的通知服务抽象成接口做行为验证,再写消费者驱动的契约测试,用 httptest 起提供方真实 handler、校验消费者依赖的字段集合,并用一个改名案例演示契约漂移如何被自动抓到,最后给出 mock 与真实依赖的取舍清单。

本节把 TaskHub 推进到「依赖第三方也能测」:TaskHub 要调用外部的通知服务,这个服务不在我们手里。我们用 testify/mock 验证自己这边的调用行为,再用消费者驱动的契约测试锁住对方必须提供的 JSON 字段,让对方的改动在我们这边第一时间红。
适用版本:Go 1.27(实测 go1.27.0)+ github.com/stretchr/testify v1.12.1。

11.2 契约测试与 mock

上一节的 Testcontainers 能拉起我们自己的依赖(Postgres、Redis)。但 TaskHub 还依赖一类东西:别人的服务——公司的通知服务、第三方的支付网关。你没法在测试里起它,也不想让每次 CI 都去连它的测试环境。

这类依赖有两种测法,解决的是两个不同的问题,经常被混为一谈:

手段回答的问题谁写
mock「我的代码调用下游的行为对不对」消费者
契约测试「下游答应给我的数据形状变了吗」消费者 + 提供方

mock 是「我这边对不对」,契约是「接口还兼容吗」。两个都要有。

11.2.1 先抽象成接口,才谈得上 mock

mock 的前提是依赖一个接口而不是具体类型。TaskHub 的通知能力这样定义:

package contract

import "context"

// Notifier 是 TaskHub 依赖的下游「通知服务」抽象
type Notifier interface {
	NotifyTaskAssigned(ctx context.Context, tenantID string, taskID int64, assignee string) error
}

// AssignTask 是消费者:分配任务后通知
func AssignTask(ctx context.Context, n Notifier, tenantID string, taskID int64, assignee string) error {
	if assignee == "" {
		return ErrNoAssignee
	}
	return n.NotifyTaskAssigned(ctx, tenantID, taskID, assignee)
}

这个接口还有一个隐含契约:空 assignee 不调用下游。业务规则「不该发的通知不能发」比「调用格式对不对」更重要——多给用户发一条通知,可能比少发一条更糟。

11.2.2 用 testify/mock 验证调用行为

testify 的 mock 用「预期(expectation)」描述「应该发生什么」:

type mockNotifier struct{ mock.Mock }

func (m *mockNotifier) NotifyTaskAssigned(ctx context.Context, tenantID string, taskID int64, assignee string) error {
	args := m.Called(ctx, tenantID, taskID, assignee)
	return args.Error(0)
}

func TestAssignTask_CallsNotifierOnce(t *testing.T) {
	m := new(mockNotifier)
	m.On("NotifyTaskAssigned", mock.Anything, "acme", int64(42), "alice").Return(nil).Once()

	err := contract.AssignTask(context.Background(), m, "acme", 42, "alice")
	require.NoError(t, err)
	m.AssertExpectations(t)
	m.AssertNumberOfCalls(t, "NotifyTaskAssigned", 1)
}

三个 API 必须记住:

  • m.On(method, args...).Return(...):声明「这个方法被这些参数调用时返回这个」。
  • .Once():限定只允许调一次。不写的话可以调任意次。
  • m.AssertExpectations(t):断言所有声明的预期都确实发生了。不调它,On 只是「如果调了就返回」,调没调没人管——这是 mock 用得最错的地方。

「不该调用」也要测。空 assignee 的用例用 AssertNotCalled 反向断言:

func TestAssignTask_NoAssigneeDoesNotNotify(t *testing.T) {
	m := new(mockNotifier)
	err := contract.AssignTask(context.Background(), m, "acme", 42, "")
	require.ErrorIs(t, err, contract.ErrNoAssignee)
	m.AssertNotCalled(t, "NotifyTaskAssigned",
		mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

mock.Anything 表示「这个位置任何值都行」。反向断言的价值在于:如果哪天有人把校验挪到了下游调用之后,这条测试立刻红,提醒你「通知已经发出去了」。

11.2.3 本机实测:三个 mock 测试

$ go test ./internal/contract/ -v
=== RUN   TestAssignTask_CallsNotifierOnce
--- PASS: TestAssignTask_CallsNotifierOnce (0.00s)
=== RUN   TestAssignTask_NoAssigneeDoesNotNotify
--- PASS: TestAssignTask_NoAssigneeDoesNotNotify (0.00s)
=== RUN   TestAssignTask_PropagatesDownstreamError
--- PASS: TestAssignTask_PropagatesDownstreamError (0.00s)
PASS
ok  	demo/internal/contract	0.560s

三条分别覆盖:正常路径调一次、非法输入不调、下游错误原样上抛(require.ErrorIs 保证错误链没被包丢)。全程零 IO,几十微秒跑完——这就是 mock 的意义:把「调用行为」从「真实网络」里剥出来。

11.2.4 契约测试:锁住对方必须给的数据

mock 只能验证我们预期的调用,验证不了「对方实际返回什么」。TaskHub 的移动端 App 依赖 /v1/tasks/{id} 返回的 JSON 里有 id、title、status、assignee 四个字段。如果服务端哪天把 status 改名成 state,App 会解析出空字符串,而且没有任何测试会红——除非你写了契约测试。

消费者驱动的契约测试思路:把「消费者依赖的字段集合」显式写下来,作为一份可执行的契约:

// requiredKeys 是消费者依赖的契约字段,改这里等于改契约
var requiredKeys = []string{"id", "title", "status", "assignee"}

// checkContract 校验响应体是否满足契约,返回缺失字段
func checkContract(raw []byte) (missing []string, err error) {
	var obj map[string]json.RawMessage
	if err := json.Unmarshal(raw, &obj); err != nil {
		return nil, fmt.Errorf("响应不是合法 JSON 对象: %w", err)
	}
	for _, k := range requiredKeys {
		if _, ok := obj[k]; !ok {
			missing = append(missing, k)
		}
	}
	return missing, nil
}

注意 checkContract 只检查字段存在,不检查值的具体内容。这是有意的:消费者真正依赖的是「这些字段在」,至于 title 具体是什么值,那是另一个测试的事。契约测试要尽量窄,宽了就会因为无关改动而红。

11.2.5 用 httptest 起提供方,端到端校验契约

提供方的 handler 用 httptest.NewServer 真跑起来,消费者发真实 HTTP 请求,再拿响应去校验契约:

func TestProviderSatisfiesContract(t *testing.T) {
	srv := httptest.NewServer(providerMux()) // 提供方的真实 handler
	defer srv.Close()

	resp, err := http.Get(srv.URL + "/v1/tasks/42")
	require.NoError(t, err)
	defer resp.Body.Close()
	require.Equal(t, http.StatusOK, resp.StatusCode)

	raw := make([]byte, 4096)
	n, _ := resp.Body.Read(raw)
	raw = raw[:n]

	missing, err := checkContract(raw)
	require.NoError(t, err)
	require.Empty(t, missing, "提供方缺失契约字段")

	var dto taskDTO
	require.NoError(t, json.Unmarshal(raw, &dto))
	require.Equal(t, int64(42), dto.ID)
	require.Equal(t, "open", dto.Status)
	require.Equal(t, "u-1", dto.Assignee.ID)
}

这里有两层断言,缺一不可:

  1. checkContract 检查字段存在——形状对不对。
  2. json.Unmarshal 进消费者的 DTO 再断言——类型对不对(id 是数字不是字符串)、嵌套路径对不对(assignee.id)。

第二层是关键:checkContract 只看顶层字段名,assignee 里面把 id 改名成 uuid 它发现不了,但 DTO 的 Assignee.ID 会解成空串,require.Equal(t, "u-1", dto.Assignee.ID) 立刻红。

11.2.6 本机实测:契约满足与漂移检出

$ go test ./internal/contract/ -run 'TestProviderSatisfiesContract|TestDriftIsDetected' -v
=== RUN   TestProviderSatisfiesContract
    http_contract_test.go:80: 契约满足: id=42 status="open" assignee="u-1"
--- PASS: TestProviderSatisfiesContract (0.02s)
=== RUN   TestDriftIsDetected
    http_contract_test.go:89: 漂移被检出,缺失字段=[status]
--- PASS: TestDriftIsDetected (0.00s)
PASS
ok  	demo/internal/contract	0.558s

第二条用例是故意构造的漂移:提供方把 status 改成了 state,喂给 checkContract:

func TestDriftIsDetected(t *testing.T) {
	drifted := []byte(`{"id":42,"title":"t","state":"open","assignee":{"id":"u-1"}}`)
	missing, err := checkContract(drifted)
	require.NoError(t, err)
	require.Equal(t, []string{"status"}, missing)
}

实测输出 缺失字段=[status]——契约检查准确指出了被改名的字段。这条测试的作用不是「现在能过」,而是「证明漂移会被抓到」。它验证的是契约检查器本身有效;否则一个永远返回空 missing 的检查器会让所有契约测试假绿。

11.2.7 mock、stub、fake、真实依赖怎么选

这四个词经常混用,实际是四种不同的取舍:

手段行为适用
mock记录调用、断言预期验证「调了几次、参数对不对」
stub返回固定值,不断言只需要下游「别报错」
fake轻量真实实现(如内存 map 版 repo)需要状态又不想起容器
真实依赖Testcontainers 起的真服务验证 SQL、序列化、协议

选型的判断顺序:

  1. 被测逻辑依赖的是下游的行为(调用次数、顺序)→ mock。
  2. 只依赖返回值,不关心调用过程 → stub 就够,别过度 mock。
  3. 需要有状态的交互(写入后能读回)→ fake 或真实依赖。
  4. 依赖数据库方言、序列化格式、网络语义 → 只能真实依赖(Testcontainers)。

11.2.8 mock 的反模式:把测试写成实现的镜子

mock 用多了会有一个隐蔽的坏味道:测试开始复述实现细节。比如断言「先调了 GetTenant、再调了 GetTask、最后调了 Notify」——这种顺序耦合会让重构寸步难行:实现顺序一变,测试就红,但行为其实没变。

两条纪律:

  • 只断言对外可观察的行为,不断言内部调用顺序,除非顺序本身是业务要求(比如「先扣款再发货」)。
  • mock 的数量是设计信号:一个函数要 mock 五个依赖,说明它承担了太多职责,该拆了。mock 难写,往往是设计问题的症状,而不是测试的问题。

还有一条关于接口位置的经验:接口应该定义在消费者侧,而不是提供者侧。Notifier 定义在 TaskHub 里、由通知服务的客户端去实现,而不是通知服务导出自己的接口让 TaskHub 去依赖。消费者定义的接口只包含自己用到的方法,天然最小、最好 mock。

小结

  • mock 回答「我的调用行为对不对」,契约测试回答「对方的接口还兼容吗」,两者不可互相替代。
  • testify/mock 的 On/Return/Once 声明预期,AssertExpectations 才真正断言;反向断言用 AssertNotCalled。
  • 契约测试显式列出消费者依赖的字段集合,用 httptest 起提供方真实 handler 校验,并额外用 DTO 断言类型与嵌套路径。
  • 写一条「漂移必被检出」的测试,证明契约检查器本身有效,避免假绿。
  • 选 mock/stub/fake/真实依赖看依赖的性质;接口定义在消费者侧,mock 太多是设计信号。

单体和依赖都测好了,但真实系统是多个组件一起跑。下一节讲集成与端到端测试,重点解决一个绕不开的难题:多个测试共享一个数据库时,怎么保证数据互不污染。

阅读导航:上一节:11.1 分层测试与 Testcontainers · 下一节:11.3 集成/端到端与数据隔离 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练