Go 测试替身入门:用小接口写出容易测试的业务代码

从发送欢迎邮件的业务流程讲 Go 里的 fake、stub、spy,用小接口降低测试成本,并避免过度 mock。

很多初学者一听到“单元测试”,就想到 mock 框架。其实 Go 里最常用、最舒服的测试替身,往往只是一个小结构体。你定义一个刚好够用的小接口,测试里手写 fake 或 spy,就能验证业务逻辑,而不用真的发邮件、调支付或连外部 API。

本文用“注册用户后发送欢迎邮件”的例子,讲 fake、stub、spy 的区别,以及怎样设计小接口。

业务函数

假设注册成功后要发邮件:

type User struct {
	ID    int64
	Email string
	Name  string
}

type Mailer interface {
	SendWelcome(ctx context.Context, email string, name string) error
}

type UserStore interface {
	Create(ctx context.Context, email string, name string) (User, error)
}

服务:

type RegisterService struct {
	store  UserStore
	mailer Mailer
}

func (s *RegisterService) Register(ctx context.Context, email, name string) (User, error) {
	if strings.TrimSpace(email) == "" {
		return User{}, errors.New("email is required")
	}
	user, err := s.store.Create(ctx, email, name)
	if err != nil {
		return User{}, fmt.Errorf("create user: %w", err)
	}
	if err := s.mailer.SendWelcome(ctx, user.Email, user.Name); err != nil {
		return User{}, fmt.Errorf("send welcome: %w", err)
	}
	return user, nil
}

接口很小,只包含当前服务需要的方法。不要为了“复用”定义一个巨大 UserRepository,里面有几十个方法。接口越大,测试替身越难写。

stub:返回固定结果

测试注册成功时,store 可以是 stub:

type stubStore struct {
	user User
	err  error
}

func (s stubStore) Create(ctx context.Context, email string, name string) (User, error) {
	if s.err != nil {
		return User{}, s.err
	}
	return s.user, nil
}

它不关心输入,只返回预设结果。适合让测试走到后续逻辑。

spy:记录调用

Mailer 需要验证是否被调用:

type spyMailer struct {
	called bool
	email  string
	name   string
	err    error
}

func (m *spyMailer) SendWelcome(ctx context.Context, email string, name string) error {
	m.called = true
	m.email = email
	m.name = name
	return m.err
}

测试:

func TestRegisterSendsWelcomeEmail(t *testing.T) {
	mailer := &spyMailer{}
	service := &RegisterService{
		store: stubStore{user: User{ID: 1, Email: "a@example.com", Name: "A"}},
		mailer: mailer,
	}

	_, err := service.Register(context.Background(), "a@example.com", "A")
	if err != nil {
		t.Fatal(err)
	}
	if !mailer.called {
		t.Fatal("expected welcome email")
	}
	if mailer.email != "a@example.com" {
		t.Fatalf("email = %q", mailer.email)
	}
}

这个 spy 很短,比引入 mock 框架更容易读。测试失败时也能直接看懂。

fake:有简单行为

fake 比 stub 更像真实实现,但仍然在内存里:

type fakeUserStore struct {
	nextID int64
	users map[string]User
}

func newFakeUserStore() *fakeUserStore {
	return &fakeUserStore{nextID: 1, users: make(map[string]User)}
}

func (s *fakeUserStore) Create(ctx context.Context, email string, name string) (User, error) {
	if _, exists := s.users[email]; exists {
		return User{}, errors.New("email already exists")
	}
	user := User{ID: s.nextID, Email: email, Name: name}
	s.nextID++
	s.users[email] = user
	return user, nil
}

fake 适合测试稍复杂流程,比如重复注册、查询后更新。它不需要数据库,也不需要网络,但行为足够接近业务。

测试错误路径

如果发邮件失败,注册函数现在会返回错误:

func TestRegisterReturnsMailerError(t *testing.T) {
	mailer := &spyMailer{err: errors.New("smtp down")}
	service := &RegisterService{
		store:  stubStore{user: User{ID: 1, Email: "a@example.com", Name: "A"}},
		mailer: mailer,
	}

	_, err := service.Register(context.Background(), "a@example.com", "A")
	if err == nil {
		t.Fatal("expected error")
	}
	if !strings.Contains(err.Error(), "send welcome") {
		t.Fatalf("error = %v", err)
	}
}

这类测试能逼你想清楚业务语义:邮件失败时注册是否应该失败?有些系统会选择用户创建成功,邮件异步重试。那服务结构就会不同。测试不是为了覆盖数字,而是帮助你确认规则。

接口定义在哪里

Go 里常见建议是:接口由使用方定义。RegisterService 只需要 CreateSendWelcome,就在业务包定义这两个小接口。真实数据库 store 和真实 mailer 只要实现方法即可,不需要显式声明。

这样依赖方向更自然。业务服务不需要知道底层是 Postgres、Redis、SMTP 还是第三方邮件 API。测试也不用实现一堆用不到的方法。

避免过度 mock

如果一个测试里设置了十几个预期调用顺序,通常说明代码耦合太重。Go 测试更推荐验证可观察结果,而不是每一步内部调用。比如注册成功后,检查返回用户、检查邮件 spy 被调用即可,不必验证 store 内部执行了哪条 SQL。

也不要为了测试而给生产代码加奇怪的接口。接口应该表达真实边界:数据库、邮件、时间、随机数、外部 API。普通纯函数不需要接口,直接调用就好。

小结

Go 测试替身可以很简单。小接口让业务代码依赖抽象边界,stub 返回固定结果,spy 记录调用,fake 提供内存行为。手写这些结构体通常比复杂 mock 更清楚。

测试替身的目标不是模拟整个世界,而是隔离你不想在测试里触碰的外部依赖。接口越小,替身越好写,测试越像业务规则说明书。

继续阅读

探索更多技术文章

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

全部文章 返回首页