Go 表驱动测试与子测试
用命名用例表与 t.Run 子测试扩展输入矩阵、独立定位失败,并与 go test -run 选择执行协同。
[!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
结合场景再看三个关注点
-
name是子测试 ID
库存边界名要稳定、可读;go test -run靠它单点复现「超卖钳制」等 case。 -
一行失败不影响其他行
默认执行下,above max 的 Fatal 不阻止 inside 运行,但父测试最终仍失败;-failfast、panic 或进程退出会改变这一行为。 -
循环变量与并行
语言版本为 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()却碰全局。
正确:先隔离,再并行;默认串行也完全合格。
工程实践
- 先写表头再填行:输入维度想清楚(空、最小、最大、非法、Unicode…)。
- 一表一规则:一个
TestX对应一个行为面;别把无关 API 揉一张表。 - 辅助断言:
cmp.Diff、自定义assert+t.Helper()。 - 黄金文件:大体量输出可用
testdata/+ update 旗标(团队约定)。 - 与 fuzz 分工:表驱动固化已知边界;fuzz 挖未知输入(go-fuzz-testing)。
- 文档化不变量:在
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
为解析函数加非法输入行,断言错误路径。
本节总结
自测题
概念题
- 为什么表驱动常配合
t.Run? - slice 表相对 map 表的主要优势?
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 种状态码分支,是否全部塞进一张表?如何拆?
参考答案
展开
- 独立失败、独立名称、可 -run 过滤,避免首错短路。
- 顺序稳定、可控制编排;map 遍历顺序未指定。
- 测试/子测试全名的正则。
代码题:Go 1.22 之前的语言语义可能捕获复用的 tt;现代 := range 已修复该问题。跨用例共享可变底层数据仍需隔离,tt := tt 的浅拷贝不能解决。 工程题:按鉴权/校验/业务成功等面拆多个TestXxx表,公共夹具复用,而不是一张巨表。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Package testing — Run | 标准库 | 子测试 API |
| Table-driven tests | Wiki | 社区惯用法 |
| Go Wiki: TestComments | Wiki | 命名与失败信息 |
| Using Subtests and Sub-benchmarks | 官方博客 | 子测试/子基准设计 |
| Add a test | 教程 | 基础衔接 |