Go 单元测试与基准测试:table-driven 测试最佳实践
Go 单元测试与基准测试建议统一采用 table-driven 写法,把用例的 name、输入和期望放入结构体切片,通过 t.Run 或 b.Run 执行子测试,以便按名筛选、清晰报错和低成本覆盖边界值;同时给出标准测试骨架、失败信息与辅助函数细节,并提醒 Go 1.22 前需对循环变量做影子拷贝,大输入宜放入 testdata 并用 golden 文件管理。
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






