2205 字
约 7 分钟
15
S09-A21 · 表驱动测试的边界:什么时候它让测试变差
2024-12-31
2026-11-10

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. 自测

  1. 表驱动适合与不适合的两组情形,分界是什么?
  2. "断言靠 switch"为什么是"该拆"的强信号?
  3. "好表"的四个特征里,哪一项给了"表多大合适"的判据?
  4. t.Run 的价值是什么?t.Parallel() 在什么情况下会出问题?
  5. 为什么"加 case 成本极低"会成为问题?
  6. 判断"是否该用表"的那个问题是什么?
S09-A21 · 表驱动测试的边界:什么时候它让测试变差
https://www.clxhxhhr.top/posts/6591/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。