← [ BACK_TO_NOTES ]

Go 表驱动测试与子测试

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

#concept #growing #dev #go

[!info] related notes

Go 表驱动测试与子测试

这个概念为什么会出现

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

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

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

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

最小可运行示例

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

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

仓库调拨单里,前端滑杆可能把「调拨数量」拖到负数或超过库存上限。
本例 ClampQty 在调用方保证 min <= max 时把数量钳在区间内,覆盖低于下限、高于上限、贴边、正常值。纯函数不预留库存,也不能单独防止并发超卖。

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

// stock.go
package inventory

// ClampQty 把调拨数量钳在可售区间 [min, max]。
// 调用前提: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)
			}
		})
	}
}

把代码块分别保存为 stock.go、stock_test.go;新目录先运行 go mod init example.com/inventory,再执行:

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 的 Fatal 不阻止 inside 运行,但父测试最终仍失败;-failfast、panic 或进程退出会改变这一行为。

  3. 循环变量与并行
    语言版本为 Go 1.22+(通常由 go.mod 的 go 行决定)时,本例 for _, tt := range 每轮声明新变量;这不深拷贝 slice/map 字段,也不适用于对预先声明变量使用 = 的循环。

核心概念与准确模型

用例表的形状

常见字段:

字段作用
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 后暂停,直到父测试函数返回;父测试的 defer 因而早于这些子测试恢复执行。供子测试共享的资源应在父测试的 t.Cleanup 中释放,它会等待所有子测试完成。不要在父测试函数里用 WaitGroup 等待尚未恢复的并行子测试。

与 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 // 兼容 Go 1.22 之前的语言语义;这是浅拷贝
	t.Run(tt.name, func(t *testing.T) {
		t.Parallel()
		// 使用 tt
	})
}

Go 1.22 之前的语言语义或使用 = 复用变量时,闭包可能读到最后一轮的值。现代 := range 不需要这条影子声明;无论版本如何,复制含 slice/map/指针的结构体都不会隔离底层数据。参见 go-range-semantics。

2. 可变期望对象

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

3. 名称中的特殊字符

空格会替换为下划线,不可打印字符会转义;斜杠保留并成为 -run 的分段边界。-run 对每个斜杠分隔段分别做正则匹配,且默认不锚定;精确选择可写 -run '^TestClampQty$/^above_max$'。

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. 测试/子测试全名的正则。
    代码题:Go 1.22 之前的语言语义可能捕获复用的 tt;现代 := range 已修复该问题。跨用例共享可变底层数据仍需隔离,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/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本