1. 契约与服务
如果你测试一个类似 Sum(2, 3) 这样的函数,生活很美好:输入 → 计算 → 结果。但真实代码往往想“往外走”:读文件、从数据库取数据、调用网络、查看“当前时间”。 这时 unit 测试就开始难受了,因为外部世界是不确定的:有时网络闪了一下,有时数据库没起来,有时“时间”突然更精确了,而测试原本期望某个特定格式/精度,于是就挂了。 顺便说一句,这类事情确实发生过:精度变更 time.Now 影响了程序和测试,而它们都在等待旧行为。
unit 测试的主要目标是可重复:今天、明天、以及同事的电脑上都能以相同方式运行(当然,他肯定“什么都没动”)。 所以在 unit 测试中,我们通常不直接访问外部世界。相反,我们会把外部世界做成“可插拔”的,这样在测试里就能换成可控的替代品。
用于测试的“缝”:通过接口引入依赖
要替换依赖,它必须从外部传入,而不是在函数内部通过 NewRealDatabase() 自己创建,然后再说“哎呀,反正很快嘛”。 在 Go 中,最常见、最自然的做法是:定义一个最小接口,并把它保存在服务结构体中。 这正是我们已经会的:接口、结构体、方法、构造函数。
让我们继续这个教学用的 todo 应用。假设有一个简单的业务想法:我们想创建任务,并在创建时记录“创建时刻”。 不过今天这个“时刻”不是日历时间,而只是一个数字(int),这样就不用把注意力分散到 time.Time 上。
先定义模型:
// task.go
package todo
type Task struct {
ID int
Title string
CreatedAt int // 约定的时间轴,不是 time.Time
}
现在我们需要一个存储。对于 unit 测试来说,关键是“任务服务”不需要知道它是什么存储(内存、文件、数据库)——它只需要知道契约。
// store.go
package todo
type TaskStore interface {
NextID() int
Save(t Task) error
}
第二个契约是“时钟”。我们特意这样命名,是为了不引入 time,并且让它的含义很清楚:它只是“当前值”的来源。
// clock.go
package todo
type Clock interface {
Now() int
}
现在是同时使用这两个契约的服务:
// service.go
package todo
import "fmt"
type Service struct {
store TaskStore
clock Clock
}
func NewService(store TaskStore, clock Clock) Service {
return Service{store: store, clock: clock}
}
注意这里的效果:现在 Service 完全不会“自己去拿”存储和时钟。 它就像自助收银台上没有手的人:如果你不给它 dependencies,它什么也做不了。 但 unit 测试就开心了:我们可以给它一个 fake 世界。
2. 测试中的 stub 与 fake
stub 和 fake:相似,但做的事不同
“stub”和“fake”这两个词经常被混淆,因为它们都“不是实际对象”。 但在测试里,重要的是我们到底想控制什么:结果,还是交互。
想象你在测试送披萨。 Stub 就是你说“永远返回 10 分钟送达”,然后检查折扣逻辑。 Fake 则是你想确认自己确实给外卖打了电话,并且报的是正确地址,而不是“Pine Street 123, Potato Town”。
我们用一个小表格来总结——它能帮助大脑不混乱:
| 替代物 | 核心思想 | 通常保存什么 | 我们在测试中检查什么 |
|---|---|---|---|
| Stub | “给我一个预设答案” | 预先设定的 value/err | 函数结果(got/want) |
| Fake | “记住别人怎么调用你” | called、参数、计数器 | 调用事实/调用参数 |
(有时还会说“mock”,但今天我们故意不摆出一整个术语动物园:stub 和 fake 就够了。)
服务的小逻辑:创建任务
加入 CreateTask 方法。 它做三件很清楚的事:拿新 ID、从时钟拿“现在”、组装 Task、保存到存储中。 如果出错,就返回带上下文的错误。
// service_create.go
package todo
import "fmt"
func (s Service) CreateTask(title string) (Task, error) {
t := Task{ID: s.store.NextID(), Title: title, CreatedAt: s.clock.Now()}
if err := s.store.Save(t); err != nil {
return Task{}, fmt.Errorf("save task: %w", err)
}
return t, nil
}
这里我们使用了通过 %w 的 wrapping。 这在真实代码和测试中都很方便:你可以通过 errors.Is/As 按“语义”检查错误,而不是比较文本。 这种错误包装风格是 Go 的标准实践。
stub 存储:控制场景
先从 stub 开始。它通常只包含最少逻辑:返回预设 ID 和预设错误(或无错误)。
// service_stub_test.go
package todo
type stubStore struct {
nextID int
saveErr error
}
func (st stubStore) NextID() int { return st.nextID }
func (st stubStore) Save(t Task) error { return st.saveErr }
“固定时钟”也是一个 stub 对象:
// service_clock_test.go
package todo
type fixedClock int
func (c fixedClock) Now() int { return int(c) }
现在是成功创建的测试。它检查的是结果(got/want),而不是“具体是怎么调用存储的”。
// service_create_test.go
package todo
import "testing"
func TestService_CreateTask_OK(t *testing.T) {
svc := NewService(stubStore{nextID: 7}, fixedClock(123))
got, err := svc.CreateTask("read book")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.ID != 7 || got.CreatedAt != 123 {
t.Fatalf("got %+v, want ID=7 CreatedAt=123", got)
}
}
注意:这个测试完全不需要真实存储。 我们不读文件,不启动数据库,也不祈祷网络之神。unit 测试根本不需要这些。
fake 存储:检查交互
有时结果不够。比如,方法可能返回一个“漂亮”的对象,但实际在存储里保存了错误内容(或者根本没保存——这也可能发生)。 这时我们需要 fake:它会记住传给它的内容。
// service_fake_test.go
package todo
type fakeStore struct {
nextID int
saved []Task
}
func (fs *fakeStore) NextID() int { return fs.nextID }
func (fs *fakeStore) Save(t Task) error {
fs.saved = append(fs.saved, t)
return nil
}
然后是一个测试,用来检查“存储里保存的是我们预期的内容”:
package todo
import "testing"
func TestService_CreateTask_SavesTask(t *testing.T) {
fs := &fakeStore{nextID: 1}
svc := NewService(fs, fixedClock(50))
_, _ = svc.CreateTask("write tests")
if len(fs.saved) != 1 {
t.Fatalf("Save calls=%d, want %d", len(fs.saved), 1)
}
if fs.saved[0].Title != "write tests" {
t.Fatalf("saved title=%q, want %q", fs.saved[0].Title, "write tests")
}
}
这里需要注意,fakeStore 必须是指针,因为它要保存状态(saved)。 如果把它做成值类型,修改会发生在副本上,测试看到的就会是“空白”(而你会用“为什么你这么奇怪”的表情看着这个测试)。
3. FakeClock:在没有 time.Time 的情况下固定“时间”
一个非常常见的不稳定来源就是“当前时间”。即使你不直接比较它,它也可能影响排序、格式化、“已过期/未过期”,于是测试开始“闪烁”。
正常做法是:时间也是一种依赖。我们已经定义了带有 Now() 的 Clock 接口,返回 int。 这里再强调一次:这不是日历时间。 我们不处理小时/天/时区。我们只是希望代码能说:“创建于 123 时刻”,而测试可以控制它。
有时只用一个固定值还不够:你可能想让“时间”向前走,但又必须可预测。那就可以做一个计数型时钟。
// step_clock_test.go
package todo
type stepClock struct {
now int
step int
}
func (c *stepClock) Now() int {
v := c.now
c.now += c.step
return v
}
下面是一个展示确定性的简短测试:
package todo
import "testing"
func TestStepClock(t *testing.T) {
c := &stepClock{now: 10, step: 5}
if c.Now() != 10 || c.Now() != 15 {
t.Fatalf("unexpected Now sequence")
}
}
这是一个很好的“教学抽象”例子:我们获得了可重复性,而没有碰真实的 time,在那里会立刻出现格式、地点、UTC/local、单调时钟以及成年生活中的各种快乐。
4. Table-driven 测试:多个场景,一个框架
现在把这些组合成更“实战”的形式:针对 CreateTask 的 table-driven 测试,我们检查两个场景:成功和保存失败。 对于错误场景,我们会注入一个返回预设错误的 stub 存储。
先定义一个标记错误(sentinel)。我们已经知道怎么做了,这比比较字符串方便得多。
// errors_test.go
package todo
import "errors"
var errDiskFull = errors.New("disk full")
现在是 table-driven 测试:
// service_table_test.go
package todo
import (
"errors"
"testing"
)
func TestService_CreateTask_Table(t *testing.T) {
tests := []struct {
name string
store TaskStore
clock Clock
wantErr error
}{
{"ok", stubStore{nextID: 1}, fixedClock(10), nil},
{"save_error", stubStore{nextID: 1, saveErr: errDiskFull}, fixedClock(10), errDiskFull},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := NewService(tt.store, tt.clock)
_, err := svc.CreateTask("x")
if tt.wantErr == nil && err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.wantErr != nil && !errors.Is(err, tt.wantErr) {
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
}
})
}
}
注意这里的组合:table-driven + t.Run + 可控依赖。 最终测试读起来像场景(“ok”、“save_error”),而不是“某种神秘条件集合”。
5. 这如何贴合架构
最好在脑海里保留一个简单的图景。在生产环境中,Service 会使用真实存储和真实时钟,而在 unit 测试中则使用替代品。 服务本身的逻辑保持不变。
flowchart LR
S[Service
CreateTask] --> ST[TaskStore interface]
S --> C[Clock interface]
subgraph PROD[生产环境]
ST --> R[(Real store)]
C --> RC[(Real clock)]
end
subgraph TEST[Unit 测试]
ST --> FS[stub/fake store]
C --> FC[fixed/step clock]
end
这张图几乎就是 Go 代码带接口为什么通常很好测的原因:接口成为“缝”,现实可以在那里被替换成可控模型。
6. 典型错误
错误 1:在函数内部创建依赖,而不是把它们传进来。
如果你在 CreateTask 内部做诸如“打开文件、创建客户端、获取当前时间”之类的事,测试很快就会变成迷你集成测试,时好时坏。
正确的做法是从外部传入依赖(构造函数或参数),这样测试才能注入 stub/fake。
错误 2:混淆 stub 和 fake,导致测试要么太弱,要么太脆。
当你只检查结果时,带参数检查的 fake 可能是多余的,并且会让测试变脆:你开始“测试实现”,而不是行为。
反过来,如果你需要确认交互(确实保存了该保存的内容),stub 就会太“聋”,你会漏掉 bug。
按测试目标选择替代物:stub 用于控制返回值,fake 用于控制调用。
错误 3:把 fake 做成值类型,而它其实应该保存状态。
记录调用参数的 fake 几乎总是应该是指针(*fakeStore)。
如果做成值类型,你很容易遇到“在 Save 里面写进去了,但外面是空的”这种情况,因为测试看到的是另一个副本。
错误 4:在 unit 测试里使用真实时间,然后惊讶于“闪烁式”失败。
当测试依赖“现在”时,它就会开始依赖机器速度、调度器、随机延迟,甚至时间精度/格式的变化。
所以最好把时间变成依赖,并在测试里把它固定住。
而且,是的,在我们的教学版本中,FakeClock 返回 int,就是为了避免把日历复杂度带到不需要它的地方。
错误 5:比较错误文本,而不是比较语义。
如果你包装错误(fmt.Errorf("...": %w, err)),那么直接比较字符串会很脆:你改了文本,测试就坏了,虽然行为没变。
更稳定的做法是使用 errors.Is(err, want)
(或者需要类型上下文时用 errors.As)。
这种做法直接受标准库支持,也是现代 Go 中错误处理的基础风格。
GO TO FULL VERSION