Go 单元测试:表驱动、子测试与 Test Double¶
要点卡¶
30 秒回答
Go 单测的核心不是表驱动语法,而是把输入、依赖和可观察结果做成确定性契约。表驱动 + t.Run 适合覆盖同一行为的多组边界;fake 适合有状态协议,stub 返回固定值,mock 只在“交互本身就是契约”时使用。时间、随机数、ID 和外部 I/O 应通过小接口或函数注入,测试不能靠 sleep 和真实公网稳定性。
3 分钟展开
- 测试层次:纯函数单测最多;边界 adapter 做集成测;关键用户路径做少量端到端。
- 表驱动:案例有清晰名称,断言业务结果和错误类别,不只比字符串。
- 并行测试:共享 fixture 必须隔离;
t.Parallel不是加速按钮。 - Test Double:优先手写小 fake;mock 框架用于验证顺序、次数等交互契约。
| 记忆槽 | 内容 |
|---|---|
| 三个不变量 | 测试必须确定且可重复;断言业务契约而非实现偶然;Test Double 类型由要验证的风险决定 |
| 手画图 | use case → small port → fake/stub/mock | real integration,外层补少量 E2E |
| 项目落点 | 用实际账本、状态机或链适配器说明纯业务测试、数据库集成测试和外部 sandbox 如何分层;只引用本人实际参与部分和可解释指标 |
| 一个取舍 | fake 能验证有状态协议且维护成本低;mock 精确验证交互但更易与实现细节耦合 |
错误表达
- ❌ “用了表驱动就代表覆盖充分;给所有子测试加
t.Parallel只会更快。” - ✅ “表驱动只是组织方式;并行测试必须隔离 fixture、环境变量和外部资源。”
自测追问:何时应使用 mock 验证次数/顺序?为什么 sleep 和真实公网会让测试 flaky?
10 分钟版(原理 + 图示)¶
flowchart TB
UseCase["Use case"] --> Pure["pure/domain tests"]
UseCase --> Port["small dependency port"]
Port --> Fake["in-memory fake"]
Port --> Integration["real DB/RPC integration"]
Contract["critical contract"] --> E2E["small E2E set"]
func TestService_Debit(t *testing.T) {
tests := []struct {
name string
balance int64
amount int64
want int64
wantErr error
}{
{"success", 100, 40, 60, nil},
{"insufficient", 30, 40, 30, ErrInsufficientBalance},
{"zero rejected", 100, 0, 100, ErrInvalidAmount},
}
for _, tt := range tests {
// 此处没有 t.Parallel,t.Run 会等子测试返回,因此也没有跨迭代异步捕获。
t.Run(tt.name, func(t *testing.T) {
repo := &fakeRepo{balance: tt.balance}
err := NewService(repo).Debit(context.Background(), tt.amount)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("error = %v, want %v", err, tt.wantErr)
}
if repo.balance != tt.want {
t.Fatalf("balance = %d, want %d", repo.balance, tt.want)
}
})
}
}
若子测试调用 t.Parallel(),闭包会在外层循环继续后运行:package language version 为
Go 1.22+ 时 range 每轮变量独立;仍按旧语言语义编译的 package 才需要 tt := tt 或把
tt 显式作为参数。不能只根据本机安装的 Go toolchain 版本判断。
四类 double
| 类型 | 用途 | 风险 |
|---|---|---|
| dummy | 只满足参数,不被使用 | 被意外调用时要立即失败 |
| stub | 返回预设值 | 容易只测 happy path |
| fake | 可工作的内存实现 | 语义可能与真实 DB 偏离 |
| mock/spy | 验证调用次数、参数、顺序 | 与实现细节过度耦合 |
Fake repository 不能假装具备真实数据库的隔离级别、唯一约束和锁语义;这些必须由集成测试验证。
确定性设计
- 注入
Clock.Now(),不要在测试里等待真实分钟跳变。 - 注入 ID generator / RNG,并固定 seed。
- 并发逻辑用 channel/barrier 协调,不用
time.Sleep猜调度。 t.TempDir、t.Setenv、t.Cleanup管理资源;testing明确禁止在 parallel test 或具有 parallel ancestor 的测试中调用t.Setenv,因为环境变量是进程级状态。
生产场景¶
- 资金扣减:单测验证领域不变量;MySQL 集成测验证唯一键、事务和死锁重试。
- RPC adapter:录制固定响应只能测解析;超时、限流和 provider 差异需契约测试。
- Webhook:表驱动覆盖乱序、重复、缺签名、旧时间戳和冲正。
排查与工具¶
go test ./...
go test -shuffle=on -count=20 ./...
go test -run 'TestService_Debit/insufficient' ./...
Flaky test 先找共享状态、时间依赖、goroutine 未退出和端口冲突,不要直接增加 retry 掩盖。
架构取舍¶
如果一个 use case 需要 mock 十几个对象,优先检查职责是否过宽。测试友好通常来自清晰边界,不来自给每个具体类型套一层接口。
深挖问答¶
- 覆盖率多少合格? → 没有通用数字;门禁关注关键包、变化覆盖和风险分支,覆盖率不代表断言质量。
- 何时用 golden file? → 大型稳定输出;更新必须人工 review,不能失败后自动接受。
- 测试私有函数吗? → 优先通过公开行为;复杂纯算法可提取为独立包或函数。
- mock DB 能替代集成测吗? → 不能验证 SQL、锁、隔离和 driver 行为。
- 如何测取消? → 用可控 fake 阻塞,在收到
ctx.Done()后返回并断言 goroutine 退出。
反模式与事故¶
- 单测调用真实第三方 API,网络波动导致 CI 随机失败。
time.Sleep(100 * time.Millisecond)等待 goroutine。- 只断言
err != nil,没有验证错误类别与状态未被部分写入。 - 为实现细节写大量 mock expectation,重构不改行为却全线失败。