编程 Go 1.28 的 synctest.Subtest:把 t.Run 和 synctest.Test 合成一层

2026-10-06 21:01:53

Go 1.28 的 synctest.Subtest:把 t.Run 和 synctest.Test 合成一层

Go 1.28 新增 testing/synctest.Subtest,对应提案 golang/go#77320,该提案已接受。

synctest.Test 在 Go 1.25 已经 GA。它把函数放进隔离的 bubble 中运行。bubble 内的 time 包使用假时钟,启动的 goroutines 也属于这个 bubble。synctest.Wait 会阻塞,直到 bubble 内除当前 goroutine 外的所有 goroutine 都处于 durably blocked。synctest.Sleep(d) 等价于 time.Sleep(d) 再加一次 Wait()。这些机制让依赖时间和并发的测试可以在受控时钟下推进。

Subtest 的实现很直接:

func Subtest(t *testing.T, name string, f func(*testing.T)) {
    t.Helper()
    t.Run(name, func(t *testing.T) {
        t.Helper()
        Test(t, f)
    })
}

它等价于调用 t.Run,并在其中调用 Test。也就是把子测试的注册和 bubble 的创建放在同一个函数里。

动机来自缩进。单独使用 synctest.Test 会引入一层缩进;testing.T.Run 又引入一层;表格驱动测试本身通常还有一层循环和子测试。三层叠起来后,闭包里的测试体往往要往右移很多,阅读和修改都变得别扭。把 t.Run 与 synctest.Test 合并后,可以去掉一层缩进。

另一个动机是手写 helper 容易复制粘贴出错。一个容易出现的错误是:

func TestB(t *testing.T) {
    synctest.Test(t, testA)
}

这段代码看起来是在给 TestB 创建 bubble,但实际跑的是 testA。如果测试很多,这种错误不一定立刻暴露,可能表现为覆盖了错误的行为,或者两个测试跑了同一段逻辑。Subtest 让调用点直接写明名字和函数,减少这种复制粘贴错误。

前后对比如下。

没有 Subtest 时,表格驱动测试里常见写法是:

func TestFoo(t *testing.T) {
    tests := []struct {
        name string
        f    func(t *testing.T)
    }{
        {"case a", testA},
        {"case b", testB},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            synctest.Test(t, tt.f)
        })
    }
}

这里 t.Run 一层,synctest.Test 又一层。每个用例都要在两层闭包里执行。

使用 Subtest 后:

func TestFoo(t *testing.T) {
    tests := []struct {
        name string
        f    func(t *testing.T)
    }{
        {"case a", testA},
        {"case b", testB},
    }

    for _, tt := range tests {
        synctest.Subtest(t, tt.name, tt.f)
    }
}

t.Run(tt.name, func(t *testing.T) { synctest.Test(t, tt.f) }) 变成 synctest.Subtest(t, tt.name, tt.f)。子测试注册、bubble 创建、假时钟和 Wait/Sleep 的语义仍然由 t.Run 与 synctest.Test 承担,只是调用点少了一层。

命名也讨论过。有人建议叫 synctest.Run,因为和 testing.T.Run 对称,读起来更像在“运行”一个测试。最终名字是 Subtest。原因是 Run 被保留给未来的函数:那个函数预期不接收 *testing.T,直接开一个 bubble。也就是说,Run 这个名字要留给更底层的 bubble 入口,而不是现在的子测试封装。因此 Subtest 更准确地表达了它和 testing.T.Run 的关系。

关于假时钟和等待,需要区分几个调用。synctest.Test 负责把函数放进 bubble;bubble 里的 time 是假时钟。synctest.Wait 不是 sleep,它等的是 bubble 内除当前 goroutine 外所有 goroutine 都 durably blocked。synctest.Sleep(d) 则是 time.Sleep(d) 后再 Wait(),适合让假时钟前进并等待其他 goroutine 到达稳定状态。把这些写进表格驱动测试时,Subtest 不会改变底层语义,只是减少嵌套。

链接:

复制全文 生成海报 Go testing synctest 单元测试

推荐文章

程序员茄子在线接单