Go 单元测试与基准测试:table-driven 测试最佳实践
Go 里写测试的最佳实践只有一句话:单元测试和基准测试都走 table-driven(表驱动)——把「名字 + 输入 + 期望」放进结构体切片,用 `t.Run` / `b.Run` 跑成子测试。这样新增一个用例只加一行数据,而不是复制一整个函数。
为什么 table-driven 是 Go 的默认姿势
结论:表驱动把「测试意图」和「测试机制」彻底分开,用例变成纯数据,可筛选、可复用、可被工具处理。
最直接的好处是能按名字跑单个用例:`go test -run 'TestParse/空输入' -v`,调试时不用注释代码。其次是失败信息自带用例名,CI 日志里一眼看出是哪条数据挂了。第三是覆盖边界值的心理成本极低——顺手多写两行「空串」「超长」「负数」,覆盖率自然就上去了。
注意点只有一个:Go 1.22 之前,循环变量在所有子测试间共享,`t.Run` 闭包里必须写 `tt := tt` 做影子拷贝,否则所有用例都在跑最后一条数据。1.22 起每轮迭代生成新变量,可以不写,但团队里混用版本时显式写更保险。
单元测试的标准骨架
结论:一个能直接复制的骨架 = 匿名结构体切片 + `for range` + `t.Run` + 带 got/want 的失败信息,再加 `t.Parallel()`。
func TestParseDuration(t *testing.T) {
tests := []struct {
name string
in string
want time.Duration
wantErr bool
}{
{"秒", "30s", 30 * time.Second, false},
{"空串报错", "", 0, true},
{"非法单位", "10x", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseDuration(tt.in)
if (err != nil) != tt.wantErr {
t.Fatalf("ParseDuration(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("ParseDuration(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
几个细节值得强调:
- 失败信息写成 `函数(输入) = 实际, want 期望`,这是 Go 社区的事实标准格式,读过的人不用再翻源码。
- `t.Fatal` 必须在测试自身的 goroutine 里调用。如果用辅助函数包一层,记得在里面调 `t.Helper()`,否则报错行号会指向辅助函数而不是调用点。
- 结构体比较用 `reflect.DeepEqual` 或 `google/go-cmp` 的 `cmp.Diff`,后者能打印出字段级差异,排查成本远低于一个 `false`。
- 子测试名避免斜杠,`/` 会被解析成子测试层级,`-run` 过滤时容易踩坑。
- `t.Parallel()` 会先暂停子测试,等父测试函数返回后再并行执行,所以别在父测试里对共享状态做读写。
大输入用 testdata 和 golden 文件
结论:当输入是几百行文本、输出是长字符串时,把数据放进 `testdata/` 目录,用 `-update` 标志生成 golden 文件比对。
用 `os.ReadFile(filepath.Join("testdata", tt.name+".golden"))` 读期望值,再用一个包级 `flag.Bool("update", false, ...)` 在需要时把实际输出写回文件。这样期望值不会污染测试代码,diff 时也直观。
注意 golden 文件必须提交进版本库;CI 里绝对不能带 `-update`,否则测试永远「通过」。
基准测试:表驱动 + b.Run
结论:基准测试同样表驱动,但每个用例里的 setup 必须放在 `b.ResetTimer()` 之前,且一定要打开内存分配统计。
func BenchmarkBuildIndex(b *testing.B) {
cases := []struct{ name string; n int }{{"small-100", 100}, {"large-10000", 10000}}
for _, bc := range cases {
b.Run(bc.name, func(b *testing.B) {
data := genData(bc.n) // setup,不计入耗时
b.ReportAllocs()
b.SetBytes(int64(bc.n)) // 得到 MB/s
b.ResetTimer()
var sink int
for i := 0; i < b.N; i++ {
sink = buildIndex(data)
}
_ = sink // 防止编译器把整个循环优化掉
})
}
}
跑法:`go test -bench=. -benchmem -count=10 ./... > old.txt`,改完代码再跑一次生成 `new.txt`,用 `benchstat old.txt new.txt` 比较。`-count=10` 是为了让 benchstat 给出置信区间,单次结果波动 10% 以上很常见,别拿一次数字下结论。需要测并发时用 `b.RunParallel`。
三个最容易踩的坑
结论:循环变量捕获、子测试没并行、基准测试把 setup 算进耗时,是 table-driven 代码里最常见的三类问题。
前两个前面说过,第三个尤其阴险:如果你在 `b.N` 循环里做数据库连接或读文件,测出来的是 I/O 不是函数本身,优化前后对比会完全失真。另外随机数据必须用固定种子(`rand.New(rand.NewSource(1))`)保证可复现,`time.Sleep` 一律不用于同步——换成 channel 或 `sync.WaitGroup`。
一句话收尾:把用例写成数据、把数据写成表、让表跑成子测试,单元测试和基准测试就都能用同一套心智模型维护;再配上 `-run` 精确筛选、`-benchmem` 看分配、`benchstat` 做对比,你的 Go 测试就从「能跑」升级成「敢改」。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





