CodeGym /课程 /Go SELF /通过接口实现 stub 和 fake

通过接口实现 stub 和 fake

Go SELF
第 29 级, 课程 4
可用

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 中错误处理的基础风格。

1
任务
Go SELF, 第 29 级, 课程 4
已锁定
事件时间戳
事件时间戳
1
任务
Go SELF, 第 29 级, 课程 4
已锁定
ID 预订
ID 预订
1
任务
Go SELF, 第 29 级, 课程 4
已锁定
消息传递
消息传递
1
任务
Go SELF, 第 29 级, 课程 4
已锁定
笔记服务
笔记服务
1
调查/小测验
单元测试第 29 级,课程 4
不可用
单元测试
单元测试
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION