bigywater.tech
Go · 106

表驱动测试与子测试:并行子测试里的变量捕获

表驱动测试配合 t.Parallel 的子测试,是 Go 1.22 之前最经典的循环变量陷阱:所有子测试看到的都是最后一个用例。这篇用同一份代码、只改 go.mod 的 go 版本复现它,并验证子测试名的转义规则、-run 怎么选子测试、-parallel 如何限制并行度。

1/3 vs 3/3
同一份并行子测试代码,go.mod 写 go 1.21 与 go 1.22 时通过的子测试数
a_b#01
重名的子测试名 "a b" 第二个被命名的结果
-parallel=N
同时在跑的并行子测试最多就是 N 个(实测 1、2、4、8)

Go 1.22 之前的陷阱

实测 · 本机执行输出
  [go.mod 写 go 1.21] 退出码 1
    子测试 TestCapture/one 里看到的 tc.name 却是 "three"(它测的是别人的用例)
    子测试 TestCapture/two 里看到的 tc.name 却是 "three"(它测的是别人的用例)
    --- FAIL: TestCapture (0.00s)
    --- PASS: TestCapture/three (0.01s)
    --- FAIL: TestCapture/one (0.01s)
    --- FAIL: TestCapture/two (0.01s)
    FAIL
    FAIL	lab	0.112s
  [go.mod 写 go 1.22] 退出码 0
    --- PASS: TestCapture (0.00s)
    --- PASS: TestCapture/two (0.01s)
    --- PASS: TestCapture/one (0.01s)
    --- PASS: TestCapture/three (0.01s)
    PASS
    ok  	lab	0.119s

子测试里调用了 t.Parallel() 就会暂停,等外层循环跑完才继续,这时共享的循环变量 tc 已经是最后一个用例。go.mod 写 go 1.21 时,"one" 和 "two" 两个子测试都看到了 tc.name == "three",而且因为 tc.in 与 tc.want 同属最后一个用例,用它们互相比较是检查不出来的——必须拿子测试的名字之类的独立信息来对照。

Go 1.22 起

实测 · 本机执行输出
  x_test.go:32: 子测试名: TestNames/a_b
  x_test.go:32: 子测试名: TestNames/a_b#01
  x_test.go:32: 子测试名: TestNames/x/y
  x_test.go:32: 子测试名: TestNames/#00
  x_test.go:32: 子测试名: TestNames/UPPER
  x_test.go:32: 子测试名: TestNames/tab_here
  --- PASS: TestNames (0.00s)
  --- PASS: TestNames/a_b (0.00s)
  --- PASS: TestNames/a_b#01 (0.00s)
  --- PASS: TestNames/x/y (0.00s)
  --- PASS: TestNames/#00 (0.00s)
  --- PASS: TestNames/UPPER (0.00s)
  --- PASS: TestNames/tab_here (0.00s)

规范规定每次迭代有自己独立的变量。同一份代码在 go 1.22 的模块里全部通过。注意这是按模块的 go 版本决定的:Go 1.27.1 工具链编译 go 1.21 的模块仍然是旧语义,老项目升级工具链后不改 go.mod 不会自动获得新语义。

子测试名

实测 · 本机执行输出
  go test -run "TestNames/a_b" 运行了:TestNames, TestNames/a_b, TestNames/a_b#01
  go test -run "TestNames/^a_b#01$" 运行了:TestNames, TestNames/a_b#01

空格变成下划线,重名加 #01,空字符串是 #00,斜杠不转义(x/y 会被当作层级),制表符也变成下划线。-run 用斜杠分隔每一层,每层都是正则,所以 TestNames/a_b 匹配到两个,要精确选择就加 ^ 和 $。

并行度

实测 · 本机执行输出
  -parallel=1:同时在跑的并行子测试最多 1 个,总用时 0.356 s
  -parallel=2:同时在跑的并行子测试最多 2 个,总用时 0.245 s
  -parallel=4:同时在跑的并行子测试最多 4 个,总用时 0.168 s
  -parallel=8:同时在跑的并行子测试最多 8 个,总用时 0.134 s

-parallel=N 限制同时运行的并行测试数,实验里同时在跑的子测试最多正好是 N(1、2、4、8)。总用时从 0.356 s 降到 0.134 s(含测试二进制启动的约 0.1 秒)。

文档

实测 · 本机执行输出
  go_spec.html:Each iteration has its own separate declared variable (or variables) [Go 1.22]. The variable used by the first iteration is declared by the init statement. The variable used by each subsequent iteration is declared implicitly before executing the post statement and initialized to the value of the previous iteration's variable at
  Run runs f as a subtest of t called name. It runs f in a separate goroutine and blocks until f returns or calls t.Parallel to become a parallel test. Run reports whether f succeeded (or at least did not fail before calling t.Parallel). Run may be called simultaneously from multiple goroutines, but all such calls must return before the outer test function for t returns.

语言规范明确写着 “Each iteration has its own separate declared variable”,并标注 [Go 1.22]。t.Run 的文档说明子测试在单独的 goroutine 里运行,调用 t.Parallel 之前它阻塞外层。

分步演示

表驱动测试与子测试
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。同一份测试代码放在两个模块里,仅 go.mod 的 go 行不同(1.21 / 1.22);检查方式是比较 t.Name() 的后缀与 tc.name;并行度实验 8 个各睡 30 ms 的并行子测试,总用时含测试二进制启动;读过的文档:go_spec.html 里 for 语句一节,go doc testing.T.Run
    • 没有验证:t.Setenv 与 t.Parallel 的冲突、子测试里 t.Fatal 的传播细节、-shuffle、testing/synctest 配合表驱动测试、基于 map 的表驱动用例的随机顺序、go vet 对循环变量捕获的检查
    京ICP备2026067502号-1京公网安备11010502063487号