接着context、sync、encoding/json 三篇继续填 Go 标准库这个系列的坑。go test 每天都在跑,但它到底是怎么找到测试函数、`t.Fatal()` 失败之后代码是怎么"停下来"的、`t.Parallel()` 又是怎么让几个子测试真正并发起来的——这几个问题真去翻源码、真去跑实验,答案跟直觉都不完全一样。
用 go test -work 保留编译期的临时目录,直接看它生成了什么。
go test -work -run TestSimpleAdd ./...WORK=/var/folders/.../go-build3997077485
这个 WORK 目录下真的有一个 _testmain.go,内容是这样的:
真实生成的 _testmain.go(未删改,只省略了 import 里的空行) // Code generated by 'go test'. DO NOT EDIT. package main import ( "os" "testing" "testing/internal/testdeps" _test "testingpkg" ) var tests = []testing.InternalTest{ {"TestFatalFromGoroutine", _test.TestFatalFromGoroutine}, {"TestParallelOrder", _test.TestParallelOrder}, {"TestSimpleAdd", _test.TestSimpleAdd}, } var benchmarks = []testing.InternalBenchmark{ {"BenchmarkSimpleAdd", _test.BenchmarkSimpleAdd}, } var examples = []testing.InternalExample{ {"Example", _test.Example, "hello\n", false}, } func main() { m := testing.MainStart(testdeps.TestDeps{}, tests, benchmarks, fuzzTargets, examples) os.Exit(m.Run()) }
go test 做的事情就是:扫描包里所有 TestXxx/BenchmarkXxx/ExampleXxx 函数,把它们的名字和函数引用拼进几个切片,生成一个真正的 package main,把待测的包(_test "testingpkg")当依赖导入进来,编译成一个可执行文件再运行——跟你自己写一个 main.go 手动调用这些函数没有本质区别,只是这一步是自动生成的。Example 函数还会被顺手记录下期望的标准输出("hello\n"),运行完之后拿真实输出去对比,这就是 // Output: 注释生效的原理。
这决定了 Fatal 之后代码"停在哪、怎么停",源码注释写得很直白。
$GOROOT/src/testing/testing.go:1080 func (c *common) FailNow() { c.checkFuzzFn("FailNow") c.Fail() // Calling runtime.Goexit will exit the goroutine, which // will run the deferred functions in this goroutine, // which will eventually run the deferred lines in tRunner, // which will signal to the test loop that this test is done. ... c.mu.Lock() c.finished = true c.mu.Unlock() runtime.Goexit() }
紧接着上面这段代码,源码里 Parallel() 那一段的文档注释更直接地写明了边界:
$GOROOT/src/testing/testing.go(FailNow 的文档注释) // FailNow must be called from the goroutine running the // test or benchmark function, not from other goroutines // created during the test. Calling FailNow does not stop // those other goroutines.
runtime.Goexit() 只会终止**当前调用它的那一个 goroutine**(顺带跑完这个 goroutine 里所有还没执行的 defer),不会像 panic 那样有机会被外层 recover 拦截,也完全不会影响其他 goroutine——包括测试函数自己所在的那个 goroutine,如果 Fatal 是从别的 goroutine 里调的话。
按文档说的意思,真实写一个从子 goroutine 调用 t.Fatal 的用例试一下。
func TestFatalFromGoroutine(t *testing.T) {
done := make(chan struct{})
go func() {
defer close(done)
t.Log("goroutine 开始跑")
t.Fatal("从子 goroutine 里调用 t.Fatal")
t.Log("这行如果打印出来,说明 Fatal 之后代码还在继续跑")
}()
<-done
t.Log("主测试函数:goroutine 已经退出,主测试函数还活着,继续往下跑")
time.Sleep(100 * time.Millisecond)
t.Log("主测试函数:这行真的打印出来了")
}
goexit_test.go:12: goroutine 开始跑
goexit_test.go:13: 从子 goroutine 里调用 t.Fatal
goexit_test.go:17: 主测试函数:goroutine 已经退出,主测试函数还活着,继续往下跑
goexit_test.go:19: 主测试函数:这行真的打印出来了
--- FAIL: TestFatalFromGoroutine (0.10s)
结果跟文档说的一模一样:子 goroutine 里 t.Fatal 后面那行("这行如果打印出来")真的没有打印,证明 Goexit 确实把那个 goroutine 截停了;但主测试函数完全没被打断——两行"主测试函数"的日志都真实打印出来了,包括 Fatal 之后又睡了 100 毫秒这种"不应该发生"的事情也照常发生。测试最终确实被标记成了 FAIL(因为 Fail() 在 Goexit 之前已经设置了失败标记),但失败的判定和"停止执行"完全是两件事——如果你指望子 goroutine 里的 t.Fatal 能像主测试函数里那样立刻掐断后续逻辑,真实情况会让你的测试继续执行一堆本不该发生的断言,只是最后结果凑巧还是 FAIL。
子测试先各自跑到 Parallel() 就地暂停,父测试函数返回之后才真正一起跑起来。
func TestParallelOrder(t *testing.T) {
for _, name := range []string{"A", "B", "C"} {
name := name
t.Run(name, func(t *testing.T) {
fmt.Printf(">>> 子测试 %s 调用 Parallel() 之前\n", name)
t.Parallel()
fmt.Printf(">>> 子测试 %s 真正开始执行\n", name)
})
}
fmt.Println(">>> 父测试函数的 for 循环结束了")
}
=== RUN TestParallelOrder/A
>>> 子测试 A 调用 Parallel() 之前
=== PAUSE TestParallelOrder/A
=== RUN TestParallelOrder/B
>>> 子测试 B 调用 Parallel() 之前
=== PAUSE TestParallelOrder/B
=== RUN TestParallelOrder/C
>>> 子测试 C 调用 Parallel() 之前
=== PAUSE TestParallelOrder/C
>>> 父测试函数的 for 循环结束了
=== CONT TestParallelOrder/A
>>> 子测试 A 真正开始执行
=== CONT TestParallelOrder/C
>>> 子测试 C 真正开始执行
=== CONT TestParallelOrder/B
>>> 子测试 B 真正开始执行
A、B、C 三个子测试是顺序跑到各自的 t.Parallel() 然后暂停的(RUN A → PAUSE A → RUN B → PAUSE B → RUN C → PAUSE C),父测试函数的 for 循环在这三个都暂停完之后才真正结束。真正的并发是在父测试函数返回之后才发生的——CONT 的顺序是 A、C、B,不是 A、B、C,说明这时候三个 goroutine 才被一起唤醒,谁先抢到 CPU 完全看调度,不是写代码时候的声明顺序。源码里 t.Parallel() 的实现是往 signal 通道发一个信号让父测试的循环继续处理下一个子测试,自己则卡在 <-t.parent.barrier 上等——这个 barrier 要等父测试函数整个跑完才会被释放,所有等在上面的子测试才会一起被放行。
源码里 cleanup 列表是从末尾往前弹的,一个标准的栈结构。
$GOROOT/src/testing/testing.go:1808(runCleanup) for { var cleanup func() c.mu.Lock() if len(c.cleanups) > 0 { last := len(c.cleanups) - 1 cleanup = c.cleanups[last] c.cleanups = c.cleanups[:last] // 从末尾弹出,后注册的先执行 } c.mu.Unlock() if cleanup == nil { return nil } cleanup() }
真实注册三个 cleanup,中间故意调用 t.Fatal():
func TestCleanupOrderAndFatal(t *testing.T) {
t.Cleanup(func() { fmt.Println("cleanup 1 (最先注册)") })
t.Cleanup(func() { fmt.Println("cleanup 2") })
t.Cleanup(func() { fmt.Println("cleanup 3 (最后注册)") })
fmt.Println("即将调用 t.Fatal")
t.Fatal("故意失败,看看注册过的 cleanup 还跑不跑")
}
即将调用 t.Fatal
cleanup_test.go:14: 故意失败,看看注册过的 cleanup 还跑不跑
cleanup 3 (最后注册)
cleanup 2
cleanup 1 (最先注册)
--- FAIL: TestCleanupOrderAndFatal (0.00s)
三个 cleanup 严格按 3→2→1 的反顺序执行,而且 t.Fatal() 用 runtime.Goexit() 截断了测试函数本身,cleanup 照样一个不少地跑完了——这正是前面 FailNow 源码注释里说的"deferred lines in tRunner"在起作用:Goexit 展开当前 goroutine 时会执行所有已注册的 defer,而 tRunner(驱动每个测试函数运行的那层)自己用 defer 挂了一个会调用 runCleanup 的收尾逻辑,所以不管测试是正常返回还是被 Fatal 截断,注册过的 Cleanup 都保证会被执行到——这也是官方文档建议优先用 t.Cleanup() 而不是手写 defer 做资源清理的原因,后者如果写在 Fatal 调用之前的代码路径里,反而可能因为提前返回而漏执行。
对应上面真实跑出的那组 RUN/PAUSE/CONT 顺序,一步步展开看。
| 子测试 | 阶段 | 说明 |
|---|
跟真实运行结果完全一致:三个子测试先顺序 RUN 到 Parallel() 各自 PAUSE,父测试函数结束之后才 CONT,而且 CONT 的顺序(A、C、B)不等于启动顺序,是真并发。