跳至内容
Go 测试进阶:子测试、并行测试、Fuzz 与测试夹具

Go 测试进阶:子测试、并行测试、Fuzz 与测试夹具

2026年6月12日·
yanlong

高质量测试不是断言更多,而是在较低维护成本下稳定地发现回归。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 指南