很多初学者一听到“单元测试”,就想到 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 只需要 Create 和 SendWelcome,就在业务包定义这两个小接口。真实数据库 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 相关面试,以下概念是高频考点:
- goroutine 和线程的区别
- channel 的缓冲和非缓冲用法
- defer 的执行顺序和与返回值的关系
- map 的并发不安全性和解决方案
- interface 的隐式实现和类型断言
- slice 的底层数组和 append 机制
- GC 的基本原理和调优参数
- context 的使用场景和超时控制
- error 的包装和 errors.Is/errors.As
- 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.WaitGroup 和 context.WithTimeout 编写有退出路径的并发测试,避免 goroutine 泄漏。
常见坑与避坑指南
- 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
- 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。
defer是一个好习惯。 - 不要忽略错误:即使
defer file.Close()可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。 - 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用
sync.WaitGroup和context管理生命周期。 - 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
- 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。
延伸阅读与实践建议
读完本文后,建议完成以下实践:
- 把文中所有示例代码在自己的机器上跑一遍
- 给示例代码补充错误分支的测试用例
- 尝试基于本文内容构建一个小型完整项目
- 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
- 订阅 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 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。