t.Run 默认串行执行子测试,需显式调用 t.Parallel() 才并发;它必须在子测试开头调用,且并发子测试不可共享未加锁的可变状态,否则引发 data race。

t.Run 是 Go 测试中组织多个逻辑测试的最常用方式,但它默认串行执行。如果你需要并行跑子测试来节省时间,必须显式调用 t.Parallel() —— 否则即使写了多个 t.Run,它们也按顺序一个接一个跑。
串行是默认行为,不是 bug,而是设计选择:避免竞态、简化调试、保证 setup/teardown 可控。但这也意味着,不加 t.Parallel() 的子测试不会提速,哪怕它们彼此完全独立。
常见错误现象:
- 写了 5 个耗时 1 秒的子测试,总耗时接近 5 秒(而非 ~1 秒)
- 加了
t.Parallel()却没看到并发效果——可能因为父测试没用-p或被其他测试阻塞 - 并发后出现数据竞争或 panic,但单测里没报错——漏掉了
go test -race
实操建议:
立即学习“go语言免费学习笔记(深入)”;
-
t.Parallel()必须在子测试函数体最开头调用,否则无效 - 并发子测试不能共享可变状态(比如全局变量、未加锁的 map),否则极易触发
data race - 如果子测试依赖共享 setup(如初始化 DB 连接),应把 setup 放在父测试里,且确保并发安全;更稳妥的做法是每个子测试自己 setup + teardown
- 想验证并发是否生效?加
-v -race并观察日志时间戳是否交错
如何用 -run 精确筛选子测试执行
Go 的 -run 参数支持路径式匹配,格式为 TestParent/SubName,但它是“包含匹配”,不是正则,也不区分层级深度。
例如有如下结构:
func TestCalculator(t *testing.T) {
t.Run("Add", func(t *testing.T) { /* ... */ })
t.Run("Sub/Multiply", func(t *testing.T) { /* ... */ })
t.Run("Div/Float", func(t *testing.T) { /* ... */ })
}那么这些命令都会命中:
-
go test -run TestCalculator/Add→ 只跑 Add -
go test -run Sub→ 匹配 Sub/Multiply 和 Div/Float(因为都含 "Sub") -
go test -run "Div/Float"→ 正确匹配,注意引号防 shell 解析空格
容易踩的坑:
- 名字里带斜杠 / 的子测试,
-run会按层级解析,但匹配仍是字符串包含,不是树状遍历 - 如果子测试名含点号(如
"v1.2"),-run v1也会误匹配 —— 建议命名避开模糊字符 - 没有
-v时,-run匹配失败也不会报错,只会静默跳过,容易误以为“没写对”其实是“没输出”
t.Parallel() 和共享资源之间的冲突
子测试一旦调用 t.Parallel(),就不再受父测试生命周期约束:它可能在父测试的 setup 完成后立刻启动,也可能在父测试的 teardown 已执行完之后还在运行。
这意味着:
- 父测试里打开的文件、DB 连接、临时目录等,很可能在子测试执行中途就被 close / cleanup 了
- 如果多个并发子测试共用一个
*sql.DB实例,只要它本身是线程安全的(标准库的database/sql是),就没问题;但若共用一个未加锁的map[string]int,就会崩溃 - 想让一组子测试共享 setup/teardown 且支持并发?唯一可靠方式是再套一层
t.Run,并在外层做 setup,内层用t.Parallel()
示例结构:
func TestWithSharedDB(t *testing.T) {
db := setupTestDB(t)
defer db.Close() // ← 这个 defer 不保护并发子测试!
<pre class='brush:php;toolbar:false;'>t.Run("group", func(t *testing.T) {
// 这里做真正共享的 setup(比如清空表)
truncateTables(t, db)
t.Run("insert", func(t *testing.T) {
t.Parallel()
insertRecord(t, db)
})
t.Run("query", func(t *testing.T) {
t.Parallel()
queryRecord(t, db)
})
})}
关键点:外层 t.Run("group", ...) 的作用是让所有内层并发子测试共用同一份 setup 结果,并确保它们全部结束才退出该组。
子测试命名规则与调试成本
子测试的完整名字是 /,这个路径会被用于 -run、日志输出、覆盖率报告和 IDE 测试面板。
所以命名直接影响可维护性:
- 用
t.Run("empty input", ...)比t.Run("case1", ...)更易定位问题 - 嵌套太深(如
TestAPI/v1/POST/users/create/valid_json)会让终端日志横向溢出,IDE 显示拥挤 - 名字里不要带空格或特殊符号(如
[ ]、( )),某些 CI 工具或旧版 go test 会解析失败 - 如果用表格驱动,推荐
t.Run(fmt.Sprintf("input=%d_output=%d", tt.in, tt.out), ...),但注意长度控制,避免超 64 字符
最常被忽略的一点:子测试名字一旦固定,就构成了测试的“契约”。CI 脚本、监控告警、历史比对工具都可能依赖这个名字。改名 = 断裂信号,要同步更新所有下游引用。


















