bigywater.tech
Go 标准库内部原理

testing:go test 到底在跑什么,t.Fatal 为什么不能在子 goroutine 里调用

接着context、sync、encoding/json 三篇继续填 Go 标准库这个系列的坑。go test 每天都在跑,但它到底是怎么找到测试函数、`t.Fatal()` 失败之后代码是怎么"停下来"的、`t.Parallel()` 又是怎么让几个子测试真正并发起来的——这几个问题真去翻源码、真去跑实验,答案跟直觉都不完全一样。

真实生成 main()
go test -work 真实扒出编译期生成的 _testmain.go,证明它就是个现拼的普通 Go 程序
Goexit,不是 panic
真实验证:子 goroutine 里调 t.Fatal 完全不会打断主测试函数,只会杀掉那一个 goroutine

真实验证:go test 不是黑魔法,是现拼了一个 main() 再编译运行

用 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: 注释生效的原理。

真实源码:t.Fatal() 调的是 runtime.Goexit(),不是 panic

这决定了 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,主测试函数完全没被打断

按文档说的意思,真实写一个从子 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。

踩坑提醒: 在子 goroutine 里应该用 t.Errorf() + 手动 return,或者把错误传回主 goroutine 再调用 t.Fatal()——不要指望子 goroutine 里的 Fatal/FailNow 能帮你终止整个测试的执行流程。

真实验证:t.Parallel() 的两阶段暂停/恢复

子测试先各自跑到 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 要等父测试函数整个跑完才会被释放,所有等在上面的子测试才会一起被放行。

真实验证:t.Cleanup() 严格反序执行,Fatal 之后照样会跑

源码里 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 调用之前的代码路径里,反而可能因为提前返回而漏执行。

演示:t.Parallel() 两阶段暂停/恢复的完整时间线

对应上面真实跑出的那组 RUN/PAUSE/CONT 顺序,一步步展开看。

TestParallelOrder · 执行时间线
子测试阶段说明

跟真实运行结果完全一致:三个子测试先顺序 RUN 到 Parallel() 各自 PAUSE,父测试函数结束之后才 CONT,而且 CONT 的顺序(A、C、B)不等于启动顺序,是真并发。

参考与说明

  • 本文全部真实实验(_testmain.go 的生成内容、Fatal/Goexit 的跨 goroutine 行为、Parallel 的 RUN/PAUSE/CONT 时间线、Cleanup 的反序执行)均在本机真实运行的 Go 1.27(go1.27.1 darwin/arm64)上用 go test -v/go test -work 完成,源码引用对应本机 $GOROOT/src/testing/testing.go,行号可能随 Go 版本略有变化但机制不变。
  • Go 标准库系列前三篇:context 取消信号怎么在 goroutine 树里传播、sync.Mutex/WaitGroup/Once 内部实现、encoding/json 的 struct tag 解析与缓存。
  • 没有涉及:testing.B 里 b.N 具体是怎么自动放大到统计稳定的(几何递增 + 最短 benchtime 判定)、-race 检测器的插桩机制、go test 的覆盖率插桩(-cover)原理——这几块都够单独再写一篇。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号