Go 中的测试替身

stub、fake、mock 在 Go 中的克制用法:优先接口与手工假实现,表驱动测试,避免过度 mock 框架。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 中的测试替身

这个概念为什么会出现

单元测试要在没有真实数据库、支付网关、SMTP、时钟、随机数的情况下验证业务规则。若每次测试都连真实外部系统:

  • 脆(网络/环境抖动)
  • 难并行
  • 难构造错误路径(如何稳定模拟“库存刚好不够”?)

于是需要 test double(测试替身):在测试中替换真实依赖的物体。经典测试文献把它细分为 dummy、stub、spy、mock、fake 等;工程里不必背诵分类考试题,但要清楚:

  1. 你是在提供间接输入(返回值/错误)
  2. 还是在验证交互(调用了谁、几次、参数)
  3. 还是在用简化版真实逻辑支撑多步场景

Go 没有语言级 mock 框架,也没有“虚拟方法重写”。社区主流路径是:

  • 小接口
  • 手工 stub/fake
  • 构造器/结构体字段注入依赖
  • 必要时再用 mockgen 等生成器

目标是可维护的测试,不是验证每一个内部私有调用。

[!abstract] 一句话理解 用接口替换依赖;stub 给固定答案,fake 带简化真实行为,mock/spy 验证交互——在 Go 里优先前两者,交互验证保持克制。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:结账前校验库存,单测不连真实 DB

电商结账服务 CanBuy:查 SKU 库存,够才允许下单。
生产里 Inventory 走 SQL/Redis;单测若每次起数据库,慢、脆、难注入“库存不足 / DB 宕机”。
正确做法:服务只依赖小接口;测试里塞 stub(预设返回值),用表驱动覆盖成功、库存不足、依赖失败三条路径。

package checkout_test

import (
	"context"
	"errors"
	"testing"
)

// Inventory:使用方(结账服务)定义的依赖面——“能查库存即可”。
// 生产可接 SQL;测试接 stub。不必为了可测去 mock 框架。
type Inventory interface {
	Stock(ctx context.Context, sku string) (int, error)
}

// Service:被测业务。字段是接口,不是 *sql.DB。
type Service struct{ inv Inventory }

// CanBuy:业务规则——库存查询失败则失败;库存 < 购买量则 not enough。
func (s Service) CanBuy(ctx context.Context, sku string, n int) error {
	stock, err := s.inv.Stock(ctx, sku)
	if err != nil {
		return err
	}
	if stock < n {
		return errors.New("not enough")
	}
	return nil
}

// stubInv:测试替身(stub)——用字段直接控制间接输入。
// 教学点:不验证“调用了几次 SQL”,只控制 Stock 的返回值/错误。
type stubInv struct {
	n   int
	err error
}

func (s stubInv) Stock(context.Context, string) (int, error) {
	return s.n, s.err
}

func TestCanBuy(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name     string
		stock    int
		stockErr error
		buy      int
		wantErr  bool
	}{
		{"ok", 5, nil, 3, false},                      // 库存充足
		{"short", 2, nil, 3, true},                    // 库存不足
		{"dep error", 0, errors.New("db down"), 1, true}, // 依赖失败
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			svc := Service{inv: stubInv{n: tt.stock, err: tt.stockErr}}
			err := svc.CanBuy(context.Background(), "sku", tt.buy)
			if (err != nil) != tt.wantErr {
				t.Fatalf("err=%v wantErr=%v", err, tt.wantErr)
			}
		})
	}
}

建议运行:

go test .

期望:三个子测试均 PASS(ok 无错误;short / dep error 有错误)。

结合场景再看关注点

  1. 被测 Service 只依赖小接口——生产与测试可替换实现。
  2. stub 用数据字段控制间接输入——错误注入零成本。
  3. 表驱动覆盖成功 / 库存不足 / 依赖失败,比重型 mock 脚本更稳。

核心概念与准确模型

1. 术语地图(实用版)

名称作用典型特征
Dummy填参数位置,几乎不用nil 实现、空结构体
Stub返回预设答案固定返回值/错误
Spy记录调用,供事后断言记录参数切片
Mock预设期望交互,失败即测试失败期望调用次序/次数
Fake简化但“能工作”的实现内存仓库、map 当 DB

社区口语里常把“mock”泛指一切替身;写设计/评审时建议说具体一点。

2. 为什么 Go 偏爱手工 stub/fake

  1. 接口小:一两个方法手写实现成本低
  2. 组合优于继承:没有“为了可测而把类做成可 override”的压力
  3. 生成 mock 有成本:接口变更要重新生成;过度验证调用细节使重构变脆
  4. 表驱动文化:同一 stub 配置多行用例,比复杂 mock 脚本更清晰

经典建议与 API 约定一致:接受接口,返回具体类型;接口常由使用方定义(见 go-api-design-and-conventionsgo-compositional-interfaces)。

3. Stub:控制间接输入

type stubClock struct{ t time.Time }

func (s stubClock) Now() time.Time { return s.t }

适用:

  • 纯查询依赖
  • 错误注入
  • 时间、随机、UUID 等不稳定来源

反模式:stub 内部再藏复杂分支,变成“第二个系统”。

4. Fake:可状态化的简化实现

type fakeInv struct {
	mu   sync.Mutex
	data map[string]int
}

func (f *fakeInv) Stock(_ context.Context, sku string) (int, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	n, ok := f.data[sku]
	if !ok {
		return 0, errors.New("missing")
	}
	return n, nil
}

func (f *fakeInv) Reserve(_ context.Context, sku string, n int) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.data[sku] < n {
		return errors.New("not enough")
	}
	f.data[sku] -= n
	return nil
}

适用:

  • 多步骤用例(下单 → 扣库存 → 再查)
  • 需要近似真实不变量
  • 集成测试替身(进程内)

注意:fake 也要正确到“测试关心的不变量”为止;不要实现完整 SQL 方言。

5. Spy:记录交互但不预设脚本

type spyMailer struct {
	to []string
}

func (s *spyMailer) Send(_ context.Context, addr, body string) error {
	s.to = append(s.to, addr)
	return nil
}

// 测试末尾:
if len(m.to) != 1 || m.to[0] != "a@b.c" {
	t.Fatalf(...)
}

适合:通知是否发送、是否写审计日志等副作用断言

6. Mock:期望驱动的交互验证

当交互协议本身就是需求(例如“失败必须且只能重试 3 次”),可用 mock 库或手写期望:

type mockPay struct {
	calls int
}

func (m *mockPay) Charge(ctx context.Context, cents int) error {
	m.calls++
	if m.calls < 3 {
		return errors.New("transient")
	}
	return nil
}

生成器代表:

  • go.uber.org/mock(前 gomock)
  • stretchr/testify/mock
  • 手工足够时不要上框架

风险:

  1. 测试与实现细节耦合 → 重构全红但行为未变
  2. 期望调用顺序过严 → 并行/重排合法优化被锁死
  3. 大接口 mock 生成物巨大

7. 依赖注入是替身的前提

没有缝,就插不进替身。

// 难测:函数内部 new 死依赖
func Handle(w http.ResponseWriter, r *http.Request) {
	db, _ := sql.Open("pgx", os.Getenv("DSN"))
	// ...
}

// 可测:依赖从外注入
type Handler struct {
	Repo UserRepo
	Log  Logger
}

Go 常见注入方式:

  1. 结构体字段
  2. 函数参数
  3. 包级变量(仅限测试 hook,要非常克制)
  4. 接口字段 + 生产 main 组装具体类型

go-dependency-injection-manual

8. 接口放在哪一侧?

策略含义测试影响
使用方定义接口被测包定义 Repository 小接口替身实现该接口,自然
提供方导出大接口驱动方被迫依赖许多方法替身臃肿
无接口,测具体类型只能集成测或打补丁单测困难

经验法则:接口为了消费方的抽象与测试而存在,不是为了“先画 UML”。

9. 与表驱动测试的配合

替身配置成为表格字段:

tests := []struct {
	name string
	inv  Inventory
	// ...
}{
	{"ok", stubInv{n: 10}},
	{"fail", stubInv{err: io.EOF}},
}

或用工厂函数生成 fake 初始状态。表驱动把“多种间接输入”摊开,比嵌套 mock 脚本可读。见 go-table-driven-tests-and-subtests

10. 错误路径是替身的主战场

真实系统里错误难稳定触发;替身让你可以写:

  • 超时:return context.DeadlineExceeded
  • 部分失败:第一次错、第二次成功
  • 损坏数据:返回非法中间态

没有错误注入的测试套件,往往只证明了 happy path。

11. 时间、随机、全局状态

不稳定源替身策略
time.NowClock 接口或函数字段
rand注入 *rand.Rand 或固定种子接口
全局配置参数化 Config 结构体
环境变量测试里 t.Setenv(Go 1.17+)
type Clock interface{ Now() time.Time }

type Service struct {
	now func() time.Time // 函数字段也是一种缝
}

12. HTTP / I/O 边界的标准库替身

不必事事自研:

  • net/http/httptestNewRequestNewRecorder
  • httptest.Server:真实 TCP 上的假服务端
  • iostrings.NewReaderbytes.Bufferio.NopCloser
  • testing/fstest.MapFS:假文件系统
func TestHandler(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/x", nil)
	rr := httptest.NewRecorder()
	handler.ServeHTTP(rr, req)
	if rr.Code != 200 {
		t.Fatalf("code=%d", rr.Code)
	}
}

文档:net/http/httptesttestingtesting/fstest

边界与限制

  1. 替身验证不了真实适配器:SQL 方言、驱动行为、索引、事务隔离仍需集成测试。
  2. 假得太假:fake 的不变量若与真实系统偏离,测试绿、生产红。
  3. 过度 mock:测试变成“脚本复述实现”,失去重构保护网价值。
  4. 大接口:强制 mock 生成器;应先拆接口(见组合式接口)。
  5. 并发测试中的替身:spy 记录要同步(mutex),否则测试自己 race。
  6. ctx 取消:替身应尊重 ctx.Done(),否则测不到超时路径。
  7. 不要测试私有函数细节:优先测导出行为;私有逻辑通过行为覆盖。
  8. 包级可变全局:并行测试会串扰;用 t.Cleanup 恢复,或避免全局。

常见误区

[!warning] 误区 1:没有接口也能靠 monkey patch 测一切 生产级应优先设计可注入边界。运行时补丁脆弱且难跨平台。

[!warning] 误区 2:每个依赖都要 mock 框架 一两行 stub 更清楚。框架是工具不是默认宗教。

[!warning] 误区 3:断言所有内部调用次数 除非调用协议就是需求,否则断言最终状态/返回值更稳。

[!warning] 误区 4:用 mock 测 mock 替身本身尽量简单到不需要再测;复杂 fake 才考虑自测。

[!warning] 误区 5:接口放到基础设施包,业务强制依赖 20 个方法 会导致巨型 mock。让消费方定义小接口。

[!warning] 误区 6:集成测试与单测混为一谈 替身单测快而窄;真实 DB 测试慢而真。两者分层,不互相替代。

[!warning] 误区 7:stub 返回 nil, nil 从不模拟错误 等于放弃错误路径。

[!warning] 误区 8:在业务包为了测试导出一堆钩子 优先构造注入;测试专用导出要有清晰命名与文档,避免污染公共 API。

工程实践

选择清单

只要固定返回? → stub
需要跨步骤状态? → fake
需要断言副作用? → spy(手写记录)
协议本身是需求且复杂? → 有限 mock
跨进程真实行为? → 集成测试,少 mock

目录与命名

user/
  service.go
  service_test.go      # 同包测未导出细节(必要时)
  service/
    handler_test.go    # 或 external test package
  fake/
    inventory.go       # 可复用 fake(可选)

可复用 fake 若被多包使用,放 internal/faketestutil(注意 go-internal-packages)。

并行测试

t.Parallel()
// 替身不要用共享全局
// spy 加锁或每测试独立实例

断言库?

标准库 testing 足够;testify 等可提升可读性,但不是替身问题的核心。核心仍是依赖缝与用例设计。

生成 mock 的纪律

  1. 只对稳定、跨边界接口生成
  2. 生成物提交或 CI 生成策略明确
  3. 禁止对具体 struct 的全部方法无脑 mock
  4. 优先把接口切小

与 Contract / 集成测试的衔接

  • 单测:stub/fake 保证领域规则
  • 契约测试:验证实现是否满足接口假设
  • 集成测试:真实 DB/HTTP

金字塔比例因产品而异,但不应只有 mock 层

实验

实验 A:同一服务,三种间接输入

扩展最小示例,用表驱动覆盖:

  1. 库存充足
  2. 库存不足
  3. 依赖返回错误

观察:无需真实 DB。

实验 B:把 stub 升级为 fake

实现 Reserve,写“两次购买后库存变化”的多步测试。对比 stub 表达多步是否吃力。

实验 C:过度 mock 的脆性

写一个测试,断言 Stock 必须先于某日志方法调用。然后把日志挪到调用前——行为不变,测试失败。体会交互断言的代价。

实验 D:httptest

http.Handler 写不启动真实端口的测试;再用 httptest.NewServer 写客户端测试。对比速度与真实度。

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
)

func main() {
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "ok")
	}))
	defer ts.Close()

	res, err := http.Get(ts.URL)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	b, _ := io.ReadAll(res.Body)
	fmt.Println(res.StatusCode, string(b))
}

总结

问题答案
替身解决什么?隔离慢/脆/难控依赖,稳定测业务规则与错误路径
Go 默认手法?小接口 + 手工 stub/fake + 注入
何时 mock?交互协议本身是需求,且手写成本高
最大风险?过度验证细节导致脆测试;假实现偏离真实不变量
与集成测试?互补:替身快而窄,集成真而慢
设计前提?可注入边界;接口由需要抽象的消费方定义

一句话收束:

先设计可替换的缝,再用最简单的替身证明行为;mock 是手术刀,不是空气。

自测题

1. stub 与 fake 的核心差别是什么?

2. 为什么“接受接口、返回结构体”有利于测试?

3. 什么情况下应避免断言“方法 A 必须在方法 B 之前调用”?

4. 只有 mock、没有集成测试,最大风险是什么?

5. 如何测试依赖超时?

6. 包级全局 var db = open() 对并行测试有何伤害?

答案
  1. stub 主要提供预设答案;fake 维护简化状态与真实感行为。
  2. 参数侧用接口便于注入替身;返回具体类型避免调用方再断言接口细节。
  3. 当次序并非需求、仅为当前实现细节时;它会阻碍无害重构。
  4. 真实适配器与 SQL/网络语义未被验证,生产仍可能失败。
  5. 注入返回 context.DeadlineExceeded 的 stub,或真正尊重 ctx 取消的假依赖。
  6. 测试间共享可变状态,竞态与串扰;且难以替换为替身。

依据与延伸

创建于 2026/7/14 更新于 2026/7/15