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

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

很多初学者一听到“单元测试”,就想到 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 更清楚。

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

mock、fake、stub、spy 的选择指南

四种测试替身适用不同场景,选错会让测试变得脆弱:

类型适用场景复杂度
stub只需要固定返回值,不需要验证交互
spy需要验证"是否被调用"、“调用参数是什么”
fake需要模拟真实行为(如内存数据库、内存缓存)
mock需要严格验证调用顺序和参数

Go 社区更推荐前三种,因为手写简单、可读性好。mock 框架在复杂交互场景有用,但初学者容易写出"测试实现"而不是"测试行为"的代码。

测试替身与并发

并发代码的测试替身要特别注意竞态条件:

type spyMailer struct {
	mu     sync.Mutex
	called bool
	emails []string
}

func (m *spyMailer) SendWelcome(ctx context.Context, email string, name string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.called = true
	m.emails = append(m.emails, email)
	return nil
}

func (m *spyMailer) Calls() []string {
	m.mu.Lock()
	defer m.mu.Unlock()
	return append([]string(nil), m.emails...)
}

spy 内部有状态时,测试用 go test -race 能通过是基本门槛。不要假设测试是顺序执行的。

大型接口的拆分策略

当接口方法太多时,测试替身难以维护。先拆分:

// 不好:大而全的接口
type UserRepository interface {
	Create(ctx context.Context, u User) error
	Get(ctx context.Context, id int64) (User, error)
	Update(ctx context.Context, u User) error
	Delete(ctx context.Context, id int64) error
	List(ctx context.Context, page int) ([]User, error)
	Count(ctx context.Context) (int, error)
}

// 更好:按使用方拆分
type UserWriter interface {
	Create(ctx context.Context, u User) error
	Update(ctx context.Context, u User) error
	Delete(ctx context.Context, id int64) error
}

type UserReader interface {
	Get(ctx context.Context, id int64) (User, error)
	List(ctx context.Context, page int) ([]User, error)
	Count(ctx context.Context) (int, error)
}

RegisterService 只需要 Create,就依赖 UserWriter 而不是整个 UserRepository。接口越小,测试替身越好写,依赖关系也越清楚。

集成测试与单元测试的分层

单元测试用测试替身隔离外部依赖。集成测试则连接真实数据库、真实邮件服务。不要试图用一个测试覆盖所有级别:

// 单元测试:用 fake store
func TestRegisterUser(t *testing.T) {
	store := newFakeUserStore()
	mailer := &spyMailer{}
	svc := NewRegisterService(store, mailer)
	// 测试业务规则...
}

// 集成测试:用真实数据库
func TestRegisterUserIntegration(t *testing.T) {
	if testing.Short() {
		t.Skip("skip integration test")
	}
	db := setupTestDB(t)
	store := NewSQLUserStore(db)
	mailer := NewLoggingMailer() // 不发真实邮件,只记录
	svc := NewRegisterService(store, mailer)
	// 测试端到端流程...
}

运行单元测试:go test ./... -short
运行全部测试:go test ./...

多个依赖的组合测试

当服务依赖多个外部系统时,组合使用测试替身:

func TestRegisterWithPayment(t *testing.T) {
	store := newFakeUserStore()
	mailer := &spyMailer{}
	pay := &stubPayment{status: "success"}
	svc := NewCheckoutService(store, mailer, pay)

	user, err := svc.RegisterAndPay(context.Background(), "a@example.com", "A", "token_123")
	if err != nil {
		t.Fatal(err)
	}
	if user.ID == 0 {
		t.Fatal("expected user created")
	}
	if !mailer.called {
		t.Fatal("expected welcome email")
	}
}

测试替身越多,测试越像"说明书"——告诉读者这个服务要做什么,而不是怎么做。

不要测试私有函数

测试应该验证可观察行为,而不是内部实现。如果重构时测试大面积失败,通常说明测试耦合了实现细节。用测试替身时,只验证公共接口的输入输出和副作用(如邮件是否发送),不要验证私有函数被调用了几次。

测试替身的维护成本

手写测试替身虽然简单,但数量多时也有维护成本。如果一个接口有 5 个方法,而服务只用 1 个,测试替身写 1 个方法即可。如果发现某个测试替身被复制粘贴了 10 次,可以考虑把它提升为共享的测试工具。但不要过早抽象——先重复,再提取。

表格驱动测试与测试替身

表格驱动测试是 Go 社区推荐的测试组织方式。结合测试替身,可以覆盖大量场景:

func TestRegisterUser(t *testing.T) {
    tests := []struct {
        name     string
        email    string
        store    *fakeUserStore
        mailer   *spyMailer
        wantErr  bool
        wantMail bool
    }{
        {
            name:     "success",
            email:    "a@example.com",
            store:    newFakeUserStore(),
            mailer:   &spyMailer{},
            wantErr:  false,
            wantMail: true,
        },
        {
            name:     "empty_email",
            email:    "",
            store:    newFakeUserStore(),
            mailer:   &spyMailer{},
            wantErr:  true,
            wantMail: false,
        },
        {
            name:     "duplicate_user",
            email:    "a@example.com",
            store:    func() *fakeUserStore { s := newFakeUserStore(); s.Create(context.Background(), "a@example.com", "A"); return s }(),
            mailer:   &spyMailer{},
            wantErr:  true,
            wantMail: false,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            svc := NewRegisterService(tt.store, tt.mailer)
            _, err := svc.Register(context.Background(), tt.email, "Test")
            if (err != nil) != tt.wantErr {
                t.Fatalf("error = %v, wantErr = %v", err, tt.wantErr)
            }
            if tt.mailer.called != tt.wantMail {
                t.Fatalf("mailer.called = %v, want %v", tt.mailer.called, tt.wantMail)
            }
        })
    }
}

表格驱动测试让场景清晰可扩展,添加新用例就是加一行结构体。

时间、随机数和外部依赖的可测试性

除了数据库和邮件,时间也是测试难点。业务逻辑常有"超过 24 小时不能退款"这种规则:

type Clock interface {
    Now() time.Time
}

type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }

type fixedClock struct{ t time.Time }
func (c fixedClock) Now() time.Time { return c.t }

测试时传入固定时间,就能验证"1 天后"和"25 小时后"的边界行为。

随机数也是类似。抽奖、验证码生成,都可以通过接口替换为可预测的序列。

测试替身的性能对比

不同类型的测试替身在运行时有不同开销:

类型构建开销运行时开销调试难度
stub极低
spy极低
fake
mock框架

mock 框架需要生成代码,运行时也多了反射和断言逻辑。手写替身虽然多写了几行,但在 Go 中通常更简单直接。

错误处理策略的影响

测试替身选型还会影响错误处理策略。如果 store 和 mailer 都返回 error,你的服务要决定:

  • 先存再发:store 成功但 mailer 失败时怎么办?
  • 先发再存:mailer 成功但 store 失败怎么办?
  • 事务包装:是否需要保证"存"和"发"同时成功或同时失败?

这些问题通过测试可以暴露出来。手写测试替身时,你被迫思考每个错误分支,这比 mock 框架自动生成更深刻。

真实项目用例

在实际团队协作中,下面是几个推荐的工作流:

代码审查清单

  • 函数是否处理了所有 error 返回值
  • 并发代码是否有明确的退出路径和 WaitGroup
  • 用户输入是否经过校验和清洗
  • 敏感配置是否通过环境变量或加密存储注入
  • 测试是否覆盖了正常路径和至少一个错误路径
  • 日志是否包含足够的上下文信息但不泄露敏感数据
  • 接口设计是否符合最小接口原则

CI/CD 集成建议

  • 每次提交前运行 go fmt ./...
  • CI 中运行 go vet ./...golangci-lint run
  • 单元测试使用 go test -race ./... 检测数据竞争
  • 关键路径的 benchmark 加入回归测试
  • 使用 go mod verify 确保依赖完整性

性能调优检查点

  • 使用 pprof 分析 CPU 和内存使用
  • 关注 benchmark 的 allocs/op,减少高频路径的堆分配
  • 检查数据库查询是否使用索引
  • 确认外部 HTTP 调用有合理的超时设置
  • 缓存热点数据,但注意缓存一致性和过期策略

面试高频考点

如果你正在准备 Go 相关面试,以下概念是高频考点:

  1. goroutine 和线程的区别
  2. channel 的缓冲和非缓冲用法
  3. defer 的执行顺序和与返回值的关系
  4. map 的并发不安全性和解决方案
  5. interface 的隐式实现和类型断言
  6. slice 的底层数组和 append 机制
  7. GC 的基本原理和调优参数
  8. context 的使用场景和超时控制
  9. error 的包装和 errors.Is/errors.As
  10. sync.Mutex vs sync.RWMutex vs atomic

掌握这些概念意味着你具备了独立开发 Go 服务的基础能力。继续在实际项目中磨练,你会越来越熟悉 Go 的工程风格和最佳实践。

常见问题(FAQ)

Q: 这个特性在实际项目中真的有用吗?
A: 是的。本文介绍的技术来源于真实后端开发场景。无论是标准库工具还是工程实践,在日常服务开发中都会反复用到。

Q: Go 版本会影响示例代码吗?
A: 本文代码主要针对 Go 1.20+ 编写。较新版本(如 1.22、1.23)的语法可能有微调,但核心概念保持不变。如有版本差异,文中会特别说明。

Q: 学习 Go 应该先学标准库还是直接上框架?
A: 强烈建议先学标准库。框架是对标准库的封装和扩展。只有理解了标准库的能力边界,才能正确选择和使用框架,也才能在框架出问题时快速定位。

Q: 代码里的错误处理为什么都是显式的 if err != nil
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,不会隐藏在任何 try-catch 之后。习惯了之后,你会发现这种写法实际上降低了排查错误的难度。

Q: 并发相关代码怎么测试?
A: 使用 Go 内置的 -race 标志检测数据竞争:go test -race ./...。结合 sync.WaitGroupcontext.WithTimeout 编写有退出路径的并发测试,避免 goroutine 泄漏。

常见坑与避坑指南

  1. 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
  2. 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。defer 是一个好习惯。
  3. 不要忽略错误:即使 defer file.Close() 可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。
  4. 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用 sync.WaitGroupcontext 管理生命周期。
  5. 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
  6. 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。

延伸阅读与实践建议

读完本文后,建议完成以下实践:

  1. 把文中所有示例代码在自己的机器上跑一遍
  2. 给示例代码补充错误分支的测试用例
  3. 尝试基于本文内容构建一个小型完整项目
  4. 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
  5. 订阅 Go 官方博客,关注语言演进和最佳实践更新

参考资源

  • Go 官方网站:https://go.dev/
  • Go 标准库文档:https://pkg.go.dev/std
  • Go by Example:https://gobyexample.com/
  • Effective Go:https://go.dev/doc/effective_go
  • Go 常见问题:https://go.dev/doc/faq
  • Go 项目实战社区案例和开源项目源码

本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南