《Go 语言编程入门》8.3 测试替身与接口 mock

本节给 TaskAPI 的 Service 层做依赖隔离:先区分 dummy/stub/spy/fake/mock 五种测试替身,再说明 Go 如何用「接口即接缝」天然支持替换,然后手写一个记录调用并能注入错误的 fakeStore,用它验证标题校验、ID 回读与错误透传三条路径,并讨论为什么 Go 社区更推崇手写 fake 而非生成式 mock。

8.3 测试替身与接口 mock

上一节的测试都在测「叶子」函数:校验、内存存储。但真实项目里大量逻辑处在中间层——它自己不存取数据,而是调用别人。以 TaskAPI 为例,Service.Create 先校验标题、再调 Store.Add、最后回读任务。要测它,就得把 Store 换成一个可控的替身,否则测试会被真实存储的实现细节绑架。

本节给 TaskAPI 加上 internal/service 这一层,用手写 fake 顶替 store.TaskStore,验证 Service 的三条路径:校验失败时不该落库、成功时能拿到存储分配的 ID、存储报错时错误要原样透传。这是第 5 章「面向接口」在测试上的直接回报。

8.3.1 为什么不能直接连真实存储

假设我们图省事,直接用 store.NewMemStore() 测 Service:

svc := Service{Store: store.NewMemStore()}

它现在能跑,但埋了三颗雷:

  • 测不到失败路径。真实 MemStore 的 Add 永远返回 nil 错误,你没法验证「存储失败时 Service 怎么办」。
  • 耦合实现。如果将来 MemStore 换了 ID 分配策略,Service 的测试会跟着红,可 Service 的代码一行没改。
  • 慢且不可控。一旦存储换成数据库或网络调用,单元测试就变成了集成测试。

解药是把依赖换成测试替身(test double)——一个符合同样接口、但行为完全由你掌控的对象。

8.3.2 五种替身,一张表分清

「mock」常被当成所有替身的统称,其实它们各有分工。以 TaskAPI 为例:

类型职责在 TaskAPI 里的样子
Dummy占位,不参与逻辑传一个 nil 或空结构体充参数
Stub返回预设值Get 固定返回一条任务
Spy记录调用,供事后断言记下 Add 被调用了几次
Fake有简化实现,能真正工作用 map 实现的内存存储
Mock预设期望,自动校验「Add 必须被调用恰好 1 次」

边界并不绝对:一个对象常常同时是 Spy 与 Stub。Go 社区倾向于少提 mock、多用 fake,原因在 8.3.7 展开。

8.3.3 接口即接缝

Go 没有依赖注入框架,也不需要——接口本身就是接缝(seam)。回顾第 5 章定义的接口:

// TaskStore 抽象任务的存取。
type TaskStore interface {
	Add(t task.Task) (int64, error)
	Get(id int64) (task.Task, error)
	List() []task.Task
}

只要一个类型实现了这三个方法,就能塞进 Service.Store。真实的 MemStore 是一种实现,测试里的 fakeStore 是另一种。切换它们不需要改任何生产代码——这就是面向接口编程最实际的收益。

internal/service/service.go 长这样:

// Package service 承载 TaskAPI 的应用逻辑。
package service

import (
	"taskapi/internal/store"
	"taskapi/internal/task"
)

// Service 组合存储与领域规则。
type Service struct {
	Store store.TaskStore
}

// Create 校验标题后落库并回读任务。
func (s Service) Create(title string) (task.Task, error) {
	if err := task.ValidateTitle(title); err != nil {
		return task.Task{}, err
	}
	id, err := s.Store.Add(task.New(0, title))
	if err != nil {
		return task.Task{}, err
	}
	return s.Store.Get(id)
}

注意 Store 字段的类型是接口而不是 *store.MemStore。这一个决定,让下面所有测试成为可能。

8.3.4 手写一个 fakeStore

替身不需要任何框架,三十行就够。它同时扮演 Stub(返回预设 ID)与 Spy(记录调用次数):

// fakeStore 是手写的测试替身:记录调用并返回预设值。
type fakeStore struct {
	addCalls int
	lastAdd  task.Task
	addErr   error
	getErr   error
}

func (f *fakeStore) Add(t task.Task) (int64, error) {
	f.addCalls++
	f.lastAdd = t
	if f.addErr != nil {
		return 0, f.addErr
	}
	return 42, nil
}

func (f *fakeStore) Get(id int64) (task.Task, error) {
	if f.getErr != nil {
		return task.Task{}, f.getErr
	}
	t := f.lastAdd
	t.ID = id
	return t, nil
}

func (f *fakeStore) List() []task.Task { return nil }

三个字段各有用途:

  • addCalls 用来断言「校验失败时不该调用 Add」。
  • addErr / getErr 是可注入的错误,用来走失败路径。
  • lastAdd 缓存最后一次写入的任务,让 Get 能「回读」——fake 因此有了最小可用的行为,而不只是返回死值。

固定返回 42 这个 ID 是刻意的:它和真实 MemStore 从 1 开始自增的 ID 不同,这样一旦测试里出现了 1,你立刻知道那是真实存储的痕迹,替身没生效。

8.3.5 用编译期断言钉住接口

手写 fake 最常见的翻车是接口漂移:TaskStore 加了个方法,fake 忘了实现,直到某个测试编译失败才发现。加一行编译期断言,问题会在 fake 定义处立刻暴露:

var _ store.TaskStore = (*fakeStore)(nil)

这行代码不产生任何运行时开销,只是让编译器检查「*fakeStore 是否实现了 store.TaskStore」。约定俗成的写法是用 _ 当变量名——我们不需要这个值,只需要它的类型检查。

8.3.6 三条路径,三个测试

有了 fake,Service 的关键分支都能被精确覆盖:

func TestCreateRejectsEmptyTitle(t *testing.T) {
	f := &fakeStore{}
	svc := Service{Store: f}
	_, err := svc.Create("   ")
	if !errors.Is(err, task.ErrInvalidTitle) {
		t.Fatalf("err = %v, want ErrInvalidTitle", err)
	}
	if f.addCalls != 0 {
		t.Fatalf("Add called %d times, want 0", f.addCalls)
	}
}

func TestCreateAssignsID(t *testing.T) {
	f := &fakeStore{}
	svc := Service{Store: f}
	got, err := svc.Create("写第 8 章")
	if err != nil {
		t.Fatalf("Create: %v", err)
	}
	if got.ID != 42 {
		t.Fatalf("ID = %d, want 42", got.ID)
	}
	if f.addCalls != 1 {
		t.Fatalf("Add calls = %d, want 1", f.addCalls)
	}
}

func TestCreatePropagatesStoreError(t *testing.T) {
	sentinel := errors.New("disk full")
	f := &fakeStore{addErr: sentinel}
	svc := Service{Store: f}
	_, err := svc.Create("写第 8 章")
	if !errors.Is(err, sentinel) {
		t.Fatalf("err = %v, want %v", err, sentinel)
	}
}

三条测试各自对应一个契约:

测试验证的契约
RejectsEmptyTitle校验先于落库,非法标题不触碰存储
AssignsID成功路径回读存储分配的 ID
PropagatesStoreError存储错误被透传,不被吞掉

实测输出:

$ GOTOOLCHAIN=go1.27.0 go test ./internal/service/ -v
=== RUN   TestCreateRejectsEmptyTitle
--- PASS: TestCreateRejectsEmptyTitle (0.00s)
=== RUN   TestCreateAssignsID
--- PASS: TestCreateAssignsID (0.00s)
=== RUN   TestCreatePropagatesStoreError
--- PASS: TestCreatePropagatesStoreError (0.00s)
PASS
ok  	taskapi/internal/service	0.452s

第二条测试里 f.addCalls != 1 的断言是「Spy」用法:我们不仅看返回值,还看副作用——Add 到底被调用了几次。这是纯 Stub 做不到的。

8.3.7 为什么 Go 更推崇手写 fake

在 Java、C# 生态里,用 Mockito、Moq 自动生成 mock 是主流。Go 社区却普遍选择手写,理由有四:

  1. 接口通常很小。Go 倡导「小接口」,TaskStore 三个方法,手写成本极低。生成式工具带来的配置复杂度反而更高。
  2. 手写代码可调试。fake 就是普通 Go 代码,可以打日志、下断点、加断言;生成的 mock 是一团反射魔法,出问题难查。
  3. 不引入依赖。生成式 mock 需要一个代码生成工具进 go.mod,而本卷的原则是只用标准库。
  4. 暴露设计问题。如果你发现某个接口的 fake 特别难写(方法太多、参数太杂),那往往是接口设计过宽的信号,应该拆分而不是硬 mock。

这不是说生成式 mock 一无是处。当接口来自外部、方法又多又杂时,工具能省事。但在自己的代码里,先试着写 20 行 fake,你会得到更清晰的测试和更健康的接口。

8.3.8 小结

第 8 章到此收束。三节各解决一个层面:

节问题手段
8.1行为对不对表驱动测试 + 子测试
8.2快不快、测没测到testing.B + go test -cover
8.3依赖怎么隔离接口 + 手写 fake

一个值得记住的经验:测试难写,往往是生产代码的接口设计有问题。当你觉得「这个依赖没法替换」时,先回头看它是不是直接依赖了具体类型而非接口。测试与设计,从来是同一件事的两面。

下一章我们进入泛型:如何用类型参数让 Page[T] 一套代码服务多种元素类型,如何用 slices/maps/cmp 重写 TaskAPI 的查询逻辑,以及泛型到底该不该用。

阅读导航:上一节:8.2 基准测试与覆盖率 · 下一节:9.1 类型参数与约束 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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