Go 表驱动测试与子测试
用命名用例表与 t.Run 子测试扩展输入矩阵、独立定位失败,并与 go test -run 选择执行协同。
[!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
结合场景再看三个关注点
-
name是子测试 ID
库存边界名要稳定、可读;go test -run靠它单点复现「超卖钳制」等 case。 -
一行失败不影响其他行
above max挂了,inside仍会跑——调拨矩阵不会被第一处Fatal吞掉。 -
循环变量与并行
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()却碰全局。
正确:先隔离,再并行;默认串行也完全合格。
工程实践
- 先写表头再填行:输入维度想清楚(空、最小、最大、非法、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 遍历顺序随机。
- 测试/子测试全名的正则。
代码题:并行下循环变量/共享字段可能被覆盖;tt := tt并避免共享可变底层数据。
工程题:按鉴权/校验/业务成功等面拆多个TestXxx表,公共夹具复用,而不是一张巨表。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Package testing — Run | 标准库 | 子测试 API |
| Table-driven tests | Wiki | 社区惯用法 |
| Go Wiki: TestComments | Wiki | 命名与失败信息 |
| Using Subtests and Sub-benchmarks | 官方博客 | 子测试/子基准设计 |
| Add a test | 教程 | 基础衔接 |