Go 社区有一条近乎教条的规范:用表驱动测试(table-driven tests)。于是有人把一个 200 行的业务逻辑写成"一个表 + 一个循环"——每个 case 是一个 30 行的结构体字面量,而"这个 case 在测什么"要靠读五个字段才能还原。
表驱动测试解决的是"同类输入的多个变体",而它不解决"不同场景的多个断言"。用错地方的直接后果是"测试可读性下降",这个"当成风格问题"的后果是"它不会被修":因为"风格"没有对错,而"可读性下降"是真实成本——它让"读测试"这件事变慢,而读测试正是"改代码前"的必要步骤。
而它常被当成"风格问题"而不被纠正。
1. 表驱动适合什么
| 情形 | 是否适合 |
|---|---|
| 同一函数的多个输入输出 | 适合 |
| 边界值枚举(空、负、极值) | 适合 |
| 多个场景(每个都有不同前置) | 不适合 |
| 断言逻辑本身不同(有的查错误类型,有的查副作用) | 不适合 |
第一项与第二项的共同点是"结构与断言一致":每个 case 都能用同一个"调用—比较"形状表达。这个"形状一致"可以写成一句判据:能否用一个循环把"调用—断言"写出来。能,表合适;不能(要 if/switch),就该拆。
第三项(复杂前置)在"需要数据库/容器"时最明显:每个 case 都起一次容器,成本几分钟——于是"表"的优势(多加一个 case 很便宜)被彻底抵消。
第三项与第四项的共同点是"结构不同":有的 case 需要构造复杂前置,有的要检查"是否调用了某方法"。此时把差异塞进"表的字段"里,等于在表里写 if。
2. 三种"表写坏了"的形态
| 形态 | 表现 |
|---|---|
| 字段爆炸 | case 结构体有 8 个字段,一半是空的 |
| 断言靠 switch | 循环里按 case 名分支 |
| 前置写在字段里 | 用函数字段构造复杂前置 |
第二项是最明显的信号:"测试里出现 switch" 说明这些 case 不属于同一张表——它们只是"被放在一起"。
第三项更隐蔽:表里放了 setup func(t *testing.T),于是"表"变成了"用例注册表"第三项的"退化"很典型:表里开始出现函数字段,就说明"数据"已经装不下"场景"——此时它已经不是表驱动了。
,而它失去了表驱动的最大优点(一眼看出所有输入)。
// 反例:断言靠 switch(说明它们不该在同一张表)
for _, c := range cases {
got := f(c.in)
switch c.name {
case "empty": assert.Equal(t, "", got)
case "nil": assert.Error(t, err)
case "big": assert.True(t, called) // 检查副作用
}
}
// 正解:拆成三个独立测试
func TestF_Empty(t *testing.T) {...}
func TestF_NilReturnsError(t *testing.T) {...}
func TestF_BigCallsHook(t *testing.T) {...}
3. "好表"的四个特征
| 特征 | 说明 |
|---|---|
| 字段少(≤ 3) | 输入、期望、名字 |
| 断言统一 | 一个循环一种比较 |
| case 名说明场景 | "negative balance returns error" |
| 表本身可读 | 一屏能看完 |
第三项的"附带收益"是"失败信息自带上下文":--- FAIL: TestClamp/negative_returns_zero 一眼看出是哪个场景——而 case3 还要回去数。
第三项是"可读性"的关键:case 名要写"场景 + 期望",而不是 case1、case2。
第四项给了"表多大合适"的判据:一屏看不完的表,通常该拆。第四项的"量化"可以更具体:超过 15 个 case 或超过 120 行,基本可以判断"主题混在一起"。
// 一张合格的表(三字段,断言统一)
tests := []struct {
name string
in int
want int
}{
{"zero returns zero", 0, 0},
{"negative returns zero", -5, 0},
{"positive returns same", 7, 7},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := clamp(tt.in); got != tt.want { t.Errorf("got %d, want %d", got, tt.want) }
})
}
4. t.Run 与并行
| 做法 | 效果 |
|---|---|
t.Run(name, ...) |
子测试,可单独跑 |
t.Parallel() |
并行(注意共享状态) |
| 表里改名 | 子测试名随之变 |
第一项的价值被低估:go test -run 'TestClamp/negative' 能只跑一个 case——于是"调试某个 case"不必跑全表。
第二项的"识别"很直接:把 t.Parallel() 去掉后失败消失,说明 case 之间有共享状态(如全局配置、同一个临时目录)。
第二项在"表里有共享可变状态"时会出问题:并行跑会让 case 互相影响,于是"单独跑通过、并行跑失败"。
"表"的规模可以按"字段数 × case 数"估:乘积超过 60 就难读(3 字段 × 15 case)。而"名字"字段不算——它不承载逻辑。
// 两个判据(选更严的)
1 字段数(不含 name)> 3
2 case 数 > 15 或 行数 > 120
// 触任一条 → 按主题拆表(如"clamp/boundary" + "clamp/error")
5. 什么时候"拆开写"更好
| 情形 | 做法 |
|---|---|
| 每个 case 的前置不同 | 独立测试函数 |
| 每个 case 断言不同 | 独立测试函数 |
| case 之间需要顺序(状态机) | 独立测试或"顺序表" |
| 表超过 15 个 case | 按主题拆成多张表 |
第一项与第二项是"表驱动"的核心边界:表的假设是"形状一致",不一致就该拆。
"失败信息"值得专门优化,因为它决定了"修 bug 时要不要回去读测试":
// 差的失败信息
t.Errorf("got %d, want %d", got, tt.want)
// 好的失败信息(含输入与场景)
t.Errorf("%s: f(%d) = %d, want %d", tt.name, tt.in, got, tt.want)
// 收益:直接在输出里看到"哪个输入错了、期望什么",不必回读表
6. 排错:五种"表驱动带来的问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 失败信息看不出是哪个 case | 没 t.Run |
加子测试 |
| 单独跑过、全量跑失败 | 共享状态/并行 | 隔离状态 |
| 新 case 加错位置 | 字段太多 | 拆表 |
| 覆盖率涨但缺陷没少 | case 只测正常路径 | 补边界与错误 |
| 表越写越长 | 主题混在一起 | 按主题拆 |
第四项是"表驱动"的一个副作用:加 case 的成本极低(复制一行改数字),于是"数量涨了但覆盖的维度没变"——都是"正常输入的不同数值"。
最后一个"并行"的实践建议:默认开 t.Parallel(),但要求"每个 case 自建临时资源"。于是"并行安全"成为表的隐含约束——而它顺便暴露了"共享状态"这类隐患。
for _, tt := range tests {
tt := tt // Go 1.22 前必须,避免闭包捕获同一个变量
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir() // 每个 case 自己的资源
...
})
}
// 注意:Go 1.22 起循环变量已是每次迭代独立,tt := tt 可省
7. 边界:什么情况不必用表
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单一场景(一个 case) | 表是多余的 | 直接写 |
| 逻辑复杂的用例(多步交互) | 表装不下 | 独立测试 |
| 集成测试(起容器) | 每个 case 成本高 | 少量独立用例 |
| 需要精确控制时序 | 表难表达 | 独立测试 |
最后一条与"演进"有关的边界:表可以"先表后拆"——先用表快速补边界,等"字段开始爆炸"时再按主题拆成多张表。而"一开始就拆成十个函数"往往让人懒得加 case。
判断口径一句话:这些 case 的"调用方式与断言"是否完全一致。一致,表合适(省重复、易加 case);不一致(前置不同、断言不同),拆开写更清楚。
8. 自测
- 表驱动适合与不适合的两组情形,分界是什么?
- "断言靠 switch"为什么是"该拆"的强信号?
- "好表"的四个特征里,哪一项给了"表多大合适"的判据?
t.Run的价值是什么?t.Parallel()在什么情况下会出问题?- 为什么"加 case 成本极低"会成为问题?
- 判断"是否该用表"的那个问题是什么?