Go 测试进阶:子测试、并行测试、Fuzz 与测试夹具
高质量测试不是断言更多,而是在较低维护成本下稳定地发现回归。Go 的子测试、并行测试和 Fuzz 分别解决用例组织、执行效率和未知输入探索,三者应各司其职。
子测试让失败有名字
func TestParseAmount(t *testing.T) {
tests := []struct {
name string
input string
want int64
wantErr bool
}{
{"yuan", "12.34", 1234, false},
{"negative", "-1.00", 0, true},
{"too precise", "1.001", 0, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseAmount(tc.input)
if (err != nil) != tc.wantErr {
t.Fatalf("ParseAmount() error = %v", err)
}
if !tc.wantErr && got != tc.want {
t.Errorf("ParseAmount() = %d, want %d", got, tc.want)
}
})
}
}用例名应说明业务差异,而不是 case 1。子测试可以被精确选择:go test -run 'TestParseAmount/too_precise'。失败信息要同时包含输入、实际值和期望值,避免测试失败后还要本地复现才知道发生了什么。
t.Parallel 改变的是调度和隔离要求
调用 t.Parallel() 后,该子测试会暂停,等父测试函数返回后再与其他并行测试运行。并行用例不能共享可变全局状态、固定端口、同一数据库行或进程级环境变量。
for _, tc := range tests {
tc := tc // 对旧 Go 版本明确捕获;也让意图一眼可见
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// 每个用例创建独立 fixture。
})
}Go 1.22 起 for 循环变量在每次迭代重新创建,经典捕获陷阱已改善;若模块还支持旧版本,显式复制仍有意义。不要在并行测试中调用 t.Setenv,因为环境变量属于整个进程。
并行不是越多越好。CPU 密集测试会争抢算力,数据库集成测试可能压垮共享实例。先保证隔离,再用 -parallel 控制并发度。
Fixture 要有清楚的所有权
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
s := httptest.NewServer(routes())
t.Cleanup(s.Close)
return s
}
func TestUpload(t *testing.T) {
dir := t.TempDir()
// testdata/ 适合只读、随仓库提交的固定样本;TempDir 适合测试生成物。
_ = dir
}t.Cleanup 即使测试 Fatal 也会执行,适合回收服务器、数据库和临时资源。辅助函数调用 t.Helper() 后,失败位置会指向调用者。依赖外部时钟、随机数和网络的逻辑,应通过小接口注入;但不要为了测试把所有东西都抽象成接口。
flowchart LR
A["纯函数单测"] --> B["组件测试:真实编解码 / SQL"]
B --> C["少量集成测试"]
C --> D["端到端验证"]
A --> F["Fuzz:探索输入空间"]
Fuzz 检查不变量,而不是罗列答案
Fuzz 最适合解析器、编解码器、协议边界和任何处理不可信字节的代码。
func FuzzDecodeEncode(f *testing.F) {
f.Add([]byte(`{"name":"yanlong","age":18}`))
f.Add([]byte(`{}`))
f.Fuzz(func(t *testing.T, data []byte) {
var in User
if err := json.Unmarshal(data, &in); err != nil {
return // 无效 JSON 是允许的结果
}
out, err := json.Marshal(in)
if err != nil { t.Fatalf("marshal: %v", err) }
var again User
if err := json.Unmarshal(out, &again); err != nil {
t.Fatalf("cannot decode own output: %v", err)
}
if !reflect.DeepEqual(in, again) {
t.Fatalf("round trip changed value: %#v -> %#v", in, again)
}
})
}go test -fuzz=FuzzDecodeEncode -fuzztime=30s ./internal/codec种子语料应覆盖有意义的语法结构。Fuzzer 找到的最小失败输入会写入 testdata/fuzz/...,应像回归用例一样审查并提交。Fuzz 函数必须快速、确定、无外部副作用;不要依赖真实时间和远程服务,否则“随机失败”会掩盖真正缺陷。
可重复性比真实感更重要
- 比较结构化结果,不比较含 map 顺序的字符串。
- 时间测试注入时钟或明确等待事件,不用
Sleep(100ms)猜调度。 - 测试数据库为每个用例创建独立 schema/事务,并理解事务回滚无法撤销外部副作用。
- Golden 文件适合稳定、可审查的复杂输出;提供显式更新开关,禁止测试默认重写期望。
- 失败时保存必要工件。Go 1.26 的
T.ArtifactDir可提供由go test管理的工件目录。
一套成熟测试组合通常是:大量快速确定的单元测试,少量组件/集成测试,-race 覆盖并发路径,Fuzz 持续探索输入边界。它们互相补位,没有一种能替代全部。
进一步阅读:testing 包文档、Go Fuzzing 指南。