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、假时钟

选择顺序,从便宜到贵:

  1. 能不依赖就不依赖(纯函数最好测)。
  2. 需要固定答案:stub。
  3. 需要确认副作用:spy(比 mock 宽松,只记录不强制)。
  4. 需要一个能跑的真实行为:fake(内存存储、假时钟、假队列)。
  5. 只有“必须按特定顺序/次数调用”的交互契约,才用 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 是“第二实现”,会与真实实现漂移。三个约束:

  1. 同一组契约测试跑两遍:一遍对 fake,一遍对真实实现(testcontainers),保证行为一致。
  2. fake 只实现测试真的用到的语义,并在注释里写清“哪些行为不模拟”(如事务隔离、唯一索引)。
  3. 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 断言次数是合理的,但要把断言收窄到“次数 + 关键参数”,不要断言完整调用顺序和内部方法名。其余场景一律断言状态或输出。

关联