Go 表驱动测试与子测试

用命名用例表与 t.Run 子测试扩展输入矩阵、独立定位失败,并与 go test -run 选择执行协同。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 表驱动测试与子测试

这个概念为什么会出现

真实函数很少只有一对输入输出。Clamp、解析器、状态机、HTTP 校验……边界条件会迅速膨胀:

  • 复制粘贴十个 TestXxx_caseN → 改断言逻辑要改 N 处
  • 一个大 Test 里顺序断言 → 第一处 Fatal 吞掉后面用例
  • 失败时不知道是哪组输入

表驱动(table-driven) 把“数据”与“验证步骤”分离;子测试(subtests)t.Run(name, ...) 给每行用例独立生命周期与过滤名。二者是 Go 社区最稳的规模化单测形态。

[!abstract] 一句话理解 表驱动测试用结构体切片描述输入/期望/名称,再用 t.Run 把每一行变成可单独运行、失败互不短路的子测试。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:为库存调拨写「可售数量钳制」表驱动测试

仓库调拨单里,前端滑杆可能把「调拨数量」拖到负数或超过库存上限。
后端 ClampQty 必须把数量钳在 [0, stock],边界很多:低于下限、高于上限、贴边、正常值。

若为每个边界复制一个 TestXxx,改断言要改 N 处;表驱动 + t.Run 让每行用例独立失败、可用 -run 单点复现。

// stock.go
package inventory

// ClampQty 把调拨数量钳在可售区间 [min, max]。
// 业务意图:防止超卖与负数出库。
// 教学点:被测函数保持纯,便于表驱动穷举边界。
func ClampQty(qty, min, max int) int {
	if qty < min {
		return min
	}
	if qty > max {
		return max
	}
	return qty
}
// stock_test.go
package inventory

import "testing"

// TestClampQty 用表覆盖调拨数量的关键边界。
//
// 业务意图:一次维护「输入矩阵」,避免 5 个复制粘贴测试函数。
// 教学点:
// - 结构体切片 = 用例表;name 进入子测试全名;
// - t.Run 让单行失败不短路其他行。
func TestClampQty(t *testing.T) {
	// 假设某 SKU 库存上限 10,下限 0(不允许负库存出库)。
	tests := []struct {
		name      string
		qty, min, max int
		want      int
	}{
		{name: "below min", qty: -1, min: 0, max: 10, want: 0},
		{name: "above max", qty: 99, min: 0, max: 10, want: 10},
		{name: "inside", qty: 5, min: 0, max: 10, want: 5},
		{name: "on min", qty: 0, min: 0, max: 10, want: 0},
		{name: "on max", qty: 10, min: 0, max: 10, want: 10},
	}

	for _, tt := range tests {
		// 每一行变成独立子测试:失败信息带 name,-run 可过滤。
		t.Run(tt.name, func(t *testing.T) {
			got := ClampQty(tt.qty, tt.min, tt.max)
			if got != tt.want {
				t.Fatalf("ClampQty(%d,%d,%d)=%d, want %d",
					tt.qty, tt.min, tt.max, got, tt.want)
			}
		})
	}
}

建议运行:

go test -v .
go test -run 'TestClampQty/below_min'   # 空格在名称里常显示为下划线
go test -run 'TestClampQty/above'

期望输出(-v 成功时形态):

=== RUN   TestClampQty
=== RUN   TestClampQty/below_min
=== RUN   TestClampQty/above_max
=== RUN   TestClampQty/inside
=== RUN   TestClampQty/on_min
=== RUN   TestClampQty/on_max
--- PASS: TestClampQty (0.00s)
PASS

结合场景再看三个关注点

  1. name 是子测试 ID
    库存边界名要稳定、可读;go test -run 靠它单点复现「超卖钳制」等 case。

  2. 一行失败不影响其他行
    above max 挂了,inside 仍会跑——调拨矩阵不会被第一处 Fatal 吞掉。

  3. 循环变量与并行
    Go 1.22+ 每轮 tt 通常是新变量;子测试里再 t.Parallel() 时仍建议只读本行字段,避免共享可变夹具。

核心概念与准确模型

用例表的形状

常见字段:

字段作用
name子测试名、文档、过滤
输入字段调用参数或夹具描述
want / wantErr期望结果与错误
setup / 注释可选的前置说明(慎放函数字段,可读性权衡)

错误路径示例:

tests := []struct {
	name    string
	in      string
	want    int
	wantErr bool
}{
	{name: "empty", in: "", wantErr: true},
	{name: "ok", in: "42", want: 42},
}

子测试生命周期

TestParent
├── t.Run("case A")  → 独立失败状态
├── t.Run("case B")
└── t.Run("case C")
  • 父测试可在 t.Run 前做共享 setup
  • 每子测试用 t.Cleanup 做隔离清理
  • t.Parallel() 标在子测试函数内时,父测试需注意等待语义(testing 包会协调)

go test -run 的配合

-run 是正则。子测试全名形如 TestClamp/below_min。可用:

go test -run TestClamp
go test -run 'TestClamp/on_'

Map 表 vs slice 表

// map:键即名称;遍历顺序随机 → 失败顺序不稳定
tests := map[string]struct{ /* ... */ }{ ... }
for name, tt := range tests {
	t.Run(name, func(t *testing.T) { ... })
}

需要稳定顺序或显式排序时优先 slice。map 适合“名称即键、顺序无所谓”的场景。

子基准与子模糊

同一模式可延伸:

  • b.Run:对比多实现/多尺寸
  • fuzz 的种子与 corpus 管理是另一条线(go-fuzz-testing

边界情况与反直觉行为

1. 并行子测试共享 tt

for _, tt := range tests {
	tt := tt // 并行时显式副本仍是好习惯
	t.Run(tt.name, func(t *testing.T) {
		t.Parallel()
		// 使用 tt
	})
}

若捕获循环变量且并行,可能读到后续迭代值(取决于语言版本与写法)。并行 + 表驱动 = 显式局部副本

2. 可变期望对象

want 若是指针/slice/map,子测试间可能互相污染。每用例构造独立期望,或只比较值快照。

3. 名称中的特殊字符

t.Run 名称中的空格、斜杠等会被规范化;过滤时以 -v 打印的名为准。

4. 过厚的表

把半个系统塞进一张表会让失败难读。复杂流程:表驱动测纯函数核心,外加少量集成场景测试。

5. 只比 err != nil

wantErr bool 丢弃错误类型/信息。关键路径应 errors.Is / 匹配消息(按契约选择)。

常见误区

[!warning] 常见误区:没有 name 的匿名表 错误:只用下标 case#3
正确:语义化 name,让 CI 日志可读。

[!warning] 常见误区:在循环里直接 Fatal 却不用 t.Run 错误:第一行失败后面全跳过。
正确:t.Run 隔离;或收集多错误(通常不如子测试清晰)。

[!warning] 常见误区:表里堆完整业务对象图 错误:每行构造巨型依赖。
正确:测纯逻辑用值类型输入;重依赖用工厂/小夹具。

[!warning] 常见误区:并行默认打开 错误:所有子测试 t.Parallel() 却碰全局。
正确:先隔离,再并行;默认串行也完全合格。

工程实践

  1. 先写表头再填行:输入维度想清楚(空、最小、最大、非法、Unicode…)。
  2. 一表一规则:一个 TestX 对应一个行为面;别把无关 API 揉一张表。
  3. 辅助断言cmp.Diff、自定义 assert + t.Helper()
  4. 黄金文件:大体量输出可用 testdata/ + update 旗标(团队约定)。
  5. 与 fuzz 分工:表驱动固化已知边界;fuzz 挖未知输入(go-fuzz-testing)。
  6. 文档化不变量:在 name 或注释写清“为什么这行存在”。
// 推荐:子测试内完整调用与断言,避免父级隐式状态
t.Run(tt.name, func(t *testing.T) {
	t.Cleanup(func() { /* 还原临时文件等 */ })
	got, err := Parse(tt.in)
	// ...
})

可验证实验

实验 1:隔离失败

故意写错一行期望,确认其他子测试仍 PASS。

实验 2:-run 过滤

只运行名称匹配 above 的子测试。

实验 3:并行踩坑(可选)

在旧写法/故意共享 []byte 缓冲下打开 t.Parallel,配合 -race 观察问题。

实验 4:wantErr

为解析函数加非法输入行,断言错误路径。

本节总结

  • 本质:数据驱动 + 子测试命名空间。
  • 收益:扩展边界便宜、失败可定位、可选择性重跑。
  • 最易错:并行捕获、共享可变 want、表过重。
  • 下一步基准模糊测试 扩展反馈类型。

自测题

概念题

  1. 为什么表驱动常配合 t.Run
  2. slice 表相对 map 表的主要优势?
  3. go test -run 过滤的是什么?

代码推理题

for _, tt := range tests {
	t.Run(tt.name, func(t *testing.T) {
		t.Parallel()
		got := f(tt.in)
		if got != tt.want { t.Fatal(got) }
	})
}

在何种情况下可能不稳定?如何改?

工程思考题

HTTP handler 有 12 种状态码分支,是否全部塞进一张表?如何拆?

参考答案

展开
  1. 独立失败、独立名称、可 -run 过滤,避免首错短路。
  2. 顺序稳定、可控制编排;map 遍历顺序随机。
  3. 测试/子测试全名的正则。
    代码题:并行下循环变量/共享字段可能被覆盖;tt := tt 并避免共享可变底层数据。
    工程题:按鉴权/校验/业务成功等面拆多个 TestXxx 表,公共夹具复用,而不是一张巨表。

延伸阅读与资料来源

资料类型支撑
Package testing — Run标准库子测试 API
Table-driven testsWiki社区惯用法
Go Wiki: TestCommentsWiki命名与失败信息
Using Subtests and Sub-benchmarks官方博客子测试/子基准设计
Add a test教程基础衔接

笔记元信息

  • 建议文件名:go-table-driven-tests-and-subtests.md
  • 所属阶段:阶段五
  • 学习顺序:测试基础之后的规模化写法
  • 建议下一篇:模糊测试基准测试
  • 本篇状态:已深化(书章结构;并行与过滤细节)
创建于 2026/7/11 更新于 2026/7/15