53.6 测试替身 (mock / stub / fake)
一句话
替身按“保真度 / 成本”分五档:dummy < stub < spy < mock < fake;能用 fake 就不用 mock——fake 测行为,mock 测调用,后者会把测试绑死在实现上。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “mock 就是替身” | mock 只是五种替身之一,而且是最贵、最脆的一种;多数场景 stub 或 fake 就够 |
| “mock 越多,测试越隔离” | mock 的是“调用形状”,不是“依赖行为”。依赖一改内部实现,测试就红,即使对外行为没变 |
| “用 mock 验证调用了数据库” | 这只能证明“你调了”,不能证明“数据写对了”。真实契约要靠集成测试(见 53.3 集成测试) |
| “mock 框架生成的最可靠” | 生成的是空实现;每个 EXPECT().Return() 都是你手写的假设,假设错了测试全绿、线上全挂 |
五种替身,一张表分清
| 类型 | 做什么 | 断言什么 | 成本 | 例子 |
|---|---|---|---|---|
| Dummy | 占位,保证参数合法,从不使用 | 无 | 最低 | nil、User{} |
| Stub | 返回固定值/固定错误 | 被测代码的输出 | 低 | return ErrNotFound |
| Spy | 记录调用参数/次数 | 副作用是否发生 | 低 | calls []string |
| Mock | 预设期望,未按期望调用就失败 | 交互顺序/次数/参数 | 中 | gomock 的 EXPECT() |
| Fake | 可工作的简化实现 | 行为(状态变化) | 高 | 内存 repo、假时钟 |
选择顺序,从便宜到贵:
- 能不依赖就不依赖(纯函数最好测)。
- 需要固定答案:stub。
- 需要确认副作用:spy(比 mock 宽松,只记录不强制)。
- 需要一个能跑的真实行为:fake(内存存储、假时钟、假队列)。
- 只有“必须按特定顺序/次数调用”的交互契约,才用 mock。
🔑 mock 断言的是“怎么调用”,fake 断言的是“发生了什么”。 前者绑定实现,后者绑定行为——所以重构时 fake 测试通常不用改。
可运行的例子:fake + spy + stub
产品代码(支付服务,依赖存储与通知):
package doubles
import "errors"
var ErrAlreadyPaid = errors.New("already paid")
type Order struct {
ID string
Status string
Total int
}
type Store interface {
Get(id string) (Order, error)
Save(o Order) error
}
type Notifier interface {
Notify(userID, msg string) error
}
type Service struct {
store Store
notifier Notifier
}
func NewService(s Store, n Notifier) *Service { return &Service{store: s, notifier: n} }
func (s *Service) Pay(id, userID string) error {
o, err := s.store.Get(id)
if err != nil {
return err
}
if o.Status == "paid" {
return ErrAlreadyPaid
}
o.Status = "paid"
if err := s.store.Save(o); err != nil {
return err
}
return s.notifier.Notify(userID, "order paid: "+id)
}
测试(fake 存储 + spy 通知 + stub 错误):
package doubles
import (
"errors"
"testing"
)
// fake:有真实行为的最小实现,比 mock 更接近真实 Store。
type fakeStore struct {
orders map[string]Order
}
func (f *fakeStore) Get(id string) (Order, error) {
o, ok := f.orders[id]
if !ok {
return Order{}, errors.New("not found")
}
return o, nil
}
func (f *fakeStore) Save(o Order) error {
f.orders[o.ID] = o
return nil
}
// spy:只记录调用,测试结束后断言。
type spyNotifier struct{ calls []string }
func (s *spyNotifier) Notify(userID, msg string) error {
s.calls = append(s.calls, userID+":"+msg)
return nil
}
// stub:固定返回错误,专门测错误路径。
type failingStore struct{ err error }
func (f failingStore) Get(string) (Order, error) { return Order{}, f.err }
func (f failingStore) Save(Order) error { return f.err }
func TestPay(t *testing.T) {
t.Run("成功支付只通知一次", func(t *testing.T) {
store := &fakeStore{orders: map[string]Order{"o1": {ID: "o1", Total: 1999}}}
notify := &spyNotifier{}
svc := NewService(store, notify)
if err := svc.Pay("o1", "u1"); err != nil {
t.Fatal(err)
}
if got := store.orders["o1"].Status; got != "paid" {
t.Fatalf("status=%q, want paid", got)
}
if len(notify.calls) != 1 {
t.Fatalf("notify calls=%d, want 1", len(notify.calls))
}
})
t.Run("重复支付不重复通知", func(t *testing.T) {
store := &fakeStore{orders: map[string]Order{"o1": {ID: "o1", Status: "paid"}}}
notify := &spyNotifier{}
svc := NewService(store, notify)
if err := svc.Pay("o1", "u1"); !errors.Is(err, ErrAlreadyPaid) {
t.Fatalf("err=%v, want ErrAlreadyPaid", err)
}
if len(notify.calls) != 0 {
t.Fatalf("notify calls=%d, want 0", len(notify.calls))
}
})
t.Run("存储失败不通知", func(t *testing.T) {
svc := NewService(failingStore{err: errors.New("db down")}, &spyNotifier{})
if err := svc.Pay("o1", "u1"); err == nil {
t.Fatal("want error")
}
})
}
$ go test -v ./...
=== RUN TestPay
=== RUN TestPay/成功支付只通知一次
=== RUN TestPay/重复支付不重复通知
=== RUN TestPay/存储失败不通知
--- PASS: TestPay (0.00s)
PASS
三个测试分别验证:状态变化(fake 里的订单变成 paid)、副作用次数(通知恰好一次)、错误路径(存储失败不通知)。没有一条断言“Save 被调用了几次”——这就是行为测试。
什么时候 mock 才合理
| 场景 | 用什么 | 理由 |
|---|---|---|
| 必须发一封邮件、扣一次款 | spy / mock | 副作用次数是业务要求,必须断言 |
| 第三方 SDK 的复杂调用顺序 | mock | 顺序本身是契约(如 OAuth 两步) |
| 数据库读写 | fake repo / 真实测试库 | 你要验证状态,不是验证“调了 SQL” |
| HTTP 下游服务 | fake server / 契约测试 | mock 客户端会漏掉序列化、状态码、超时 |
| 时间、随机数 | 注入 clock / rand source | 让测试确定,不用 mock 整个 time 包 |
不要 mock 你不拥有的东西:HTTP 客户端、数据库驱动、云 SDK。它们的真实行为(重试、超时、序列化)正是你要验证的;用 fake server 或集成测试替代。替身能成立的前提是依赖可注入、接口足够窄,这是可测试性设计的一部分(见 40.1 可测试性)。
fake 的维护成本:它自己也要测
fake 是“第二实现”,会与真实实现漂移。三个约束:
- 同一组契约测试跑两遍:一遍对 fake,一遍对真实实现(testcontainers),保证行为一致。
- fake 只实现测试真的用到的语义,并在注释里写清“哪些行为不模拟”(如事务隔离、唯一索引)。
- fake 不共享可变状态:每个测试 new 一个,或提供
Reset();并行测试共享一个 map 是最隐蔽的 flake 源。
⚠️ fake 与真实实现不一致时,测试全绿、线上全挂。这是 fake 唯一的致命风险,必须用契约测试兜底(见 53.5 契约测试)。fake 造出的数据也要遵守独立、确定、可清理的原则(见 53.7 测试数据管理)。
生产落地:指名 → 可抄 → 可验 → 可退
指名:手写 fake/spy 优先;需要生成 mock 时用 go.uber.org/mock(gomock 的维护分支)或 testify/mock;生成器 mockgen / mockery。替代:接口小的时候手写 20 行 fake,比引入生成器更划算。
可抄(生成 mock 的命令形态,本机未安装验证):
go install go.uber.org/mock/mockgen@latest
mockgen -source=store.go -destination=mock_store_test.go -package=doubles
可验:本机跑 go test -v ./...,三个子测试全绿;把 Pay 里的 if o.Status == "paid" 删掉,第二个子测试应立刻红(重复支付被放过)。这一步比“看着像对”有用。
可退:
- 生成 mock 导致测试大面积误报:删除生成文件,改手写 fake(接口不变,测试不用动)。
- 测试因 mock 过度耦合实现而频繁红:把断言从“调用了谁”改成“状态/输出变成了什么”(见 53.2 单元测试)。
- fake 与真实行为不一致:先用契约测试定位差异,再修 fake;不要为了让测试绿而改 fake 的语义。
反模式
| 反模式 | 后果 |
|---|---|
| 每个依赖都 mock | 测试变成“实现快照”,重构寸步难行 |
| mock 返回什么就断言什么 | 同义反复:测的是 mock,不是产品 |
| 共享一个可变 fake | 测试顺序依赖、并行随机失败 |
| mock 第三方 HTTP 客户端 | 漏掉序列化、超时、重试、状态码 |
| 用 mock 验证 SQL | 只能证明“调了”,不能证明“写对了” |
| fake 不写“不模拟什么” | 团队误以为覆盖了事务/唯一索引 |
| 生成 mock 后不 review | EXPECT() 写错,测试通过但行为没验 |
| 断言调用顺序但业务不关心顺序 | 实现一调整就红,纯噪声 |
自检
Q1 一个测试里 mock 了 6 个依赖,每个都 EXPECT().Return(...)。这个测试在测什么?
▸ 答案:大概率在测“实现有没有按我预想的顺序调用这 6 个依赖”,而不是“业务结果对不对”。这种测试的失败通常意味着重构,而不是 bug;它把实现细节固化成了契约。改法是:能用 fake 的用 fake(内存 repo、假时钟),只在真正重要的副作用上保留 spy,断言最终状态与用户可见结果。
Q2 fake 和 mock 的本质区别是什么?为什么说“能用 fake 就不用 mock”?
▸ 答案:fake 是一个能工作的简化实现,测试通过观察它的状态变化来断言行为;mock 是预设期望的替身,测试通过“是否按期望调用”来断言交互。fake 绑定行为,重构内部实现通常不用改测试;mock 绑定调用形状,实现一改就红。只有“调用次数/顺序本身是业务要求”(发一次邮件、扣一次款)时,mock 才比 fake 更合适。
Q3 为什么不应该 mock HTTP 客户端/数据库驱动?
▸ 答案:因为这些依赖的真实行为(序列化、状态码、超时、重试、连接池、SQL 方言)正是集成层要验证的东西。mock 掉它们,等于把“真实契约”替换成你的假设;假设错了,单元测试全绿,一上环境就挂。正确做法是 fake server(httptest)或 testcontainers 起真实依赖,见 53.3 集成测试。
Q4 fake 自己需要测试吗?怎么防止它和真实实现漂移?
▸ 答案:需要。fake 是第二实现,必须用同一组契约/行为测试跑两遍:一遍对 fake,一遍对真实实现(容器或测试库)。如果两边结果不一致,说明 fake 的语义漏了(事务、唯一索引、并发)。另外要在 fake 上注明“不模拟什么”,避免团队误以为覆盖了这些行为。
Q5 什么时候可以接受“断言调用了某个函数”?
▸ 答案:当这个调用本身就是业务契约时。例如“支付成功必须发一次通知”“同一订单不能重复扣款”“审计日志必须写一条”。这时用 spy/mock 断言次数是合理的,但要把断言收窄到“次数 + 关键参数”,不要断言完整调用顺序和内部方法名。其余场景一律断言状态或输出。
关联
- 前置:53.2 单元测试、40.1 可测试性
- 并列:53.3 集成测试、53.5 契约测试
- 下游:53.7 测试数据管理