本节把 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)
}
这里有两层断言,缺一不可:
checkContract检查字段存在——形状对不对。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、序列化、协议 |
选型的判断顺序:
- 被测逻辑依赖的是下游的行为(调用次数、顺序)→ mock。
- 只依赖返回值,不关心调用过程 → stub 就够,别过度 mock。
- 需要有状态的交互(写入后能读回)→ fake 或真实依赖。
- 依赖数据库方言、序列化格式、网络语义 → 只能真实依赖(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 集成/端到端与数据隔离 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。