Go 语言原本 Go 语言原本
English

16.4 代码测试

在许多语言中,测试框架由社区提供,不同的项目可能使用不同的框架、断言库与运行器,运行一个陌生项目的测试之前,往往要先弄清它用的是哪一套。Go 把测试放进了工具链:go test 是 go 命令的子命令,testing 是标准库的一个包,二者之间只靠几条命名约定衔接。本节先说明 go test 如何依据这些约定把测试组织成一个可执行程序,再讨论 testing 包的设计取向:报告原语为何如此朴素、子测试如何同时解决参数化与并行,以及模糊测试如何把覆盖率引导的输入生成纳入同一套约定。最后概述近几个版本对 testing 包的扩充。

16.4.1 从约定到可执行程序

go test 依据文件名与函数签名发现测试:以 _test.go 结尾的文件只在测试时编译,其中形如 func TestXxx(*testing.T) 的函数是测试,func BenchmarkXxx(*testing.B) 是基准测试,func FuzzXxx(*testing.F) 是模糊测试,func ExampleXxx() 是示例;示例函数末尾若有以 Output: 开头的注释,运行时会把函数的标准输出与之比较 [Go Authors, n.d.a]。这里没有配置文件,也没有注解 [Go Authors, n.d.d]。下面是一个按字节反转字符串的函数及其测试,本节的例子都以它为对象;按字节反转的 Reverse 也是官方模糊测试教程所用的例子 [Go Authors, n.d.e]:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package strutil

// Reverse 按字节反转字符串。
func Reverse(s string) string {
	b := []byte(s)
	for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
		b[i], b[j] = b[j], b[i]
	}
	return string(b)
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package strutil

import (
	"testing"
	"unicode/utf8"
)

func TestReverse(t *testing.T) {
	tests := []struct {
		name, in, want string
	}{
		{"empty", "", ""},
		{"single", "a", "a"},
		{"ascii", "abc", "cba"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			if got := Reverse(tt.in); got != tt.want {
				t.Errorf("Reverse(%q) = %q, want %q", tt.in, got, tt.want)
			}
		})
	}
}

func FuzzReverse(f *testing.F) {
	f.Add("hello")
	f.Fuzz(func(t *testing.T, s string) {
		rev := Reverse(s)
		if utf8.ValidString(s) && !utf8.ValidString(rev) {
			t.Errorf("Reverse(%q) = %q, not valid UTF-8", s, rev)
		}
	})
}

约定如何变成可以运行的程序?go test 为被测包生成一个 main 包,把找到的测试函数登记在表中,然后把它与被测包、测试文件一起链接成测试二进制并运行。生成代码的模板在 cmd/go/internal/load/test.go 中 [Go Authors, 2026a];以 Go 1.26.10 运行 go test -work,可以在工作目录中看到为上面的包生成的文件,这里删去了模板留下的空行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Code generated by 'go test'. DO NOT EDIT.

package main

import (
	"os"
	"testing"
	"testing/internal/testdeps"
	_test "example.com/strutil"
)

var tests = []testing.InternalTest{
	{"TestReverse", _test.TestReverse},
}

var benchmarks = []testing.InternalBenchmark{
}

var fuzzTargets = []testing.InternalFuzzTarget{
	{"FuzzReverse", _test.FuzzReverse},
}

var examples = []testing.InternalExample{
}

func init() {
	testdeps.ModulePath = "example.com/strutil"
	testdeps.ImportPath = "example.com/strutil"
}

func main() {
	m := testing.MainStart(testdeps.TestDeps{}, tests, benchmarks, fuzzTargets, examples)
	os.Exit(m.Run())
}

测试包若定义了 func TestMain(m *testing.M),生成的 main 会改为调用它,由它在 m.Run() 前后完成全局的准备与清理 [Go Authors, n.d.a]。go test 在构建测试二进制时还会对被测包运行 go vet 中可信度较高的一组检查,发现问题便不再运行测试;在给出包列表的模式下,它会缓存成功的测试结果,测试二进制、命令行以及测试读取的文件与环境变量都未改变时,直接输出上次的结果并标注 (cached)。这两项行为都始于 Go 1.10 [Go Authors, 2018; Go Authors, 2026b]。

16.4.2 朴素的报告原语

testing 包没有断言函数,只提供几个报告原语:t.Error 与 t.Errorf 记录失败,测试继续执行;t.Fatal 与 t.Fatalf 记录失败后调用 FailNow,后者通过 runtime.Goexit 结束当前 goroutine,运行它的全部延迟调用,然后转入下一个测试。正因为如此,FailNow 必须在运行测试函数的 goroutine 中调用,而不能在测试创建的其他 goroutine 中调用,它也不会停止那些 goroutine [Go Authors, 2026c]。

不提供断言是有意的选择。Go FAQ 的理由是:好的错误处理意味着一个检查失败之后其他检查继续运行,调试者由此看到完整的失败情形,例如 isPrime 对 2、3、5、7 都给出错误的答案,而不是只知道它在 2 上出错;测试框架又往往发展成自带条件、控制与打印机制的小语言,而 Go 本身已经具备这些能力 [Go Authors, n.d.f]。Go 的测试评审意见更直接地建议避免使用断言库:断言要么提前中止测试,要么丢掉了测试做对了什么的信息,还迫使断言包另造一套子语言,而不是复用 Go 本身 [Go Authors, n.d.c]。同一份文档建议失败消息先给出实际值再给出期望值,通常的格式是 YourFunc(%v) = %v, want %v;got 与 want 这对名字由此成为 Go 测试的惯用语。上面 TestReverse 中的失败消息就是这种格式。代价是样板代码比一行断言多,testify 这样的第三方断言库因此也有大量用户。

辅助函数带来一个具体的问题:辅助函数中调用 t.Error 时,失败报告的行号指向辅助函数内部,而不是调用它的测试,这对定位失败几乎没有帮助。Go 1.9 加入的 t.Helper 把调用者标记为辅助函数,报告位置时跳过这些帧,给出第一个非辅助函数中的位置 [Spare, 2016]。

16.4.3 表驱动测试与子测试

当失败消息需要逐个手写时,把输入与期望写成一张表、用循环逐行检查,就能把写好一条消息的成本摊到所有用例上,Go FAQ 正是这样建议的 [Go Authors, n.d.f]。这种写法在 Go 社区被称为表驱动测试 [Cheney, 2019]。早期的表驱动测试有两个不便:表中的一行无法单独运行,失败消息也必须自己带上用例的名字。van Lohuizen 在 2015 年提出的子测试解决了这些问题,所列的动机包括从命令行选择表中的单个用例、在子测试中使用 Fail 一类方法、比 TestMain 更细粒度地控制准备与清理的范围,以及更好地控制并行 [van Lohuizen, 2015]。子测试随 Go 1.7 发布 [Go Authors, 2016]。t.Run 在一个新的 goroutine 中运行子测试,直到它返回或调用 t.Parallel 才返回;子测试的名字以斜杠接在父测试之后,-run 的模式也按斜杠分段匹配 [Go Authors, 2026c]。以 Go 1.26.10 运行,删去了耗时:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ go test -v -run TestReverse
=== RUN   TestReverse
=== RUN   TestReverse/empty
=== PAUSE TestReverse/empty
=== RUN   TestReverse/single
=== PAUSE TestReverse/single
=== RUN   TestReverse/ascii
=== PAUSE TestReverse/ascii
=== CONT  TestReverse/empty
=== CONT  TestReverse/ascii
=== CONT  TestReverse/single
--- PASS: TestReverse
    --- PASS: TestReverse/empty
    --- PASS: TestReverse/ascii
    --- PASS: TestReverse/single
PASS

输出展示了并行子测试的调度方式:每个子测试调用 t.Parallel 后暂停(PAUSE),t.Run 随即返回,循环继续创建下一个子测试;父测试的函数体执行完毕后,暂停的子测试才一起继续(CONT)。父测试直到全部子测试结束才算完成,一组并行子测试因此只与彼此并行 [Go Authors, n.d.a]。若只想运行其中一行,用 go test -run TestReverse/ascii 即可。

这种调度方式曾与 for 循环的语义一起构成一个常见的陷阱。Go 1.22 之前,for 语句声明的变量只创建一次,每次迭代更新它的值 [Go Authors, 2024];并行子测试推迟到循环结束之后才执行,闭包捕获的 tt 已是最后一行,所有子测试实际上都在检查同一个用例,而且照样通过。当时的惯用写法是在闭包之前加一行 tt := tt。Go 1.22 改为每次迭代创建新的变量,这一变化按文件的语言版本生效:由 go.mod 的 go 指令或构建约束确定的版本不低于 Go 1.22 时,才采用新语义 [Go Authors, 2026d]。上面的模块声明的是 go 1.26,因此不需要那一行。

子测试之外,*testing.T 还陆续获得了管理测试资源的方法:t.Cleanup 注册在测试及其子测试结束后运行的清理函数(Go 1.14),t.TempDir 返回测试结束后自动删除的临时目录(Go 1.15),t.Setenv 在测试期间设置环境变量(Go 1.17),t.Context 返回一个在清理函数运行之前取消的上下文,t.Chdir 在测试期间切换工作目录(Go 1.24)[Go Authors, 2026c; Go Authors, 2025a]。它们的共同点是把资源的生命周期绑定到测试上。修改进程级状态的 t.Setenv 与 t.Chdir 不能与 t.Parallel 同时使用 [Go Authors, 2026c]。

16.4.4 模糊测试

表驱动测试只能检查作者想到的输入。上面的 TestReverse 全部通过,但三行用例都是 ASCII 字符串,按字节反转对它们恰好正确。模糊测试(fuzzing)换一种思路:作者只写出对所有输入都应成立的性质,由工具生成大量输入去寻找反例。FuzzReverse 检查的性质是:合法的 UTF-8 字符串反转之后仍应是合法的 UTF-8。不带 -fuzz 时,go test 只把 f.Add 提供的种子与 testdata/fuzz/FuzzReverse 目录下已有的语料当作普通用例运行一遍;带上 -fuzz 才进入模糊模式 [Go Authors, n.d.b]。以 Go 1.26.10 运行一次:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
$ go test -fuzz=FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/1 completed
fuzz: elapsed: 0s, gathering baseline coverage: 1/1 completed, now fuzzing with 18 workers
fuzz: elapsed: 0s, execs: 643 (6499/sec), new interesting: 6 (total: 7)
--- FAIL: FuzzReverse (0.11s)
    --- FAIL: FuzzReverse (0.00s)
        reverse_test.go:31: Reverse("Δ0") = "0\x94\xce", not valid UTF-8
    
    Failing input written to testdata/fuzz/FuzzReverse/022c99056fac7c47
    To re-run:
    go test -run=FuzzReverse/022c99056fac7c47
FAIL
exit status 1
FAIL	example.com/strutil	0.665s
$ cat testdata/fuzz/FuzzReverse/022c99056fac7c47
go test fuzz v1
string("Δ0")

不到一秒、六百多次执行之后,引擎找到了反例:Δ 在 UTF-8 中占两个字节,按字节反转把它拆开了。每次运行找到的具体输入会不同。引擎先以种子语料测得基准覆盖率,然后在多个工作进程中变异输入,保留能扩大覆盖率的那些(输出中的 new interesting)。这些生成的语料保存在 $GOCACHE/fuzz 中;导致失败的输入则写入 testdata/fuzz/FuzzReverse,随代码一起提交后,此后每一次普通的 go test 都会运行它,这个缺陷由此成为一个永久的回归用例 [Go Authors, n.d.b; Go Authors, 2022]。覆盖率引导依赖编译器插桩,文档建议在支持插桩的平台上运行模糊测试,目前是 amd64 与 arm64 [Go Authors, n.d.b]。

Go 的模糊测试汇合了两条源流。一条是覆盖率引导的模糊测试:Hockman 的设计草案以 AFL 与 libFuzzer 为参照,并指出 Go 社区已有的 go-fuzz 等工具成功地发现了大量缺陷,但缺少与工具链的原生集成 [Hockman, 2020]。另一条是基于性质的随机测试,源头是 Claessen 与 Hughes 的 QuickCheck:不写具体用例,而写应当恒成立的性质,由工具随机生成输入去证伪 [Claessen and Hughes, 2000]。标准库中的 testing/quick 属于这一脉,它的文档写明该包已经冻结,不再接受新功能 [Go Authors, 2026e]。FuzzReverse 的写法兼有二者:断言的是一条性质,输入由覆盖率反馈驱动生成。草案定稿为提案 #44551,模糊测试随 Go 1.18 发布 [Go Authors, 2022]。

16.4.5 基准测试与并发测试

基准测试使用同一套约定,函数形如 func BenchmarkXxx(b *testing.B)。Go 1.24 引入的 b.Loop 取代了传统的 b.N 循环:每次 -count 中基准函数只执行一次,代价较高的准备与清理也只执行一次;函数调用的参数与结果被保持存活,编译器无法把循环体整个优化掉 [Go Authors, 2025a]。b.N 如何收敛、b.Loop 如何实现以及内存度量等内容,见 16.5。

并发代码的测试有两个专门的工具。以 -race 运行测试,竞态检测器会报告测试执行到的数据竞争(16.2)。Go 1.25 正式发布的 testing/synctest 在一个隔离的「气泡」中运行测试,气泡内的时间是虚拟的,所有 goroutine 都持久阻塞时时钟立即前进,依赖超时与定时器的测试因此既快又确定 [Go Authors, 2025b];它对气泡内死锁的检测见 16.1.4。

16.4.6 近年的扩充

最近几个版本为 testing 包增加的接口,大多服务于测试与外部系统的衔接 [Go Authors, 2025a; Go Authors, 2025b; Go Authors, 2026f]:

版本新增作用
Go 1.24T.Context、T.Chdir、B.Loop绑定到测试生命周期的上下文与工作目录;新的基准循环
Go 1.25T.Attr、T.Output、testing/synctest向测试日志输出键值属性,其含义交给持续集成系统与测试框架;获取与 t.Log 同一输出流的 io.Writer;并发测试的虚拟时间
Go 1.26T.ArtifactDir、testing/cryptotest.SetGlobalRandom保存测试产物的目录;为密码学函数设定确定的随机源

ArtifactDir 返回的目录在给出 go test -artifacts 时位于输出目录之下,可供事后检查,否则是测试结束后删除的临时目录 [Go Authors, 2026f]。SetGlobalRandom 的出现与 Go 1.26 的另一项变化有关:crypto 各包生成密钥等函数不再使用调用者传入的随机源,而总是使用安全的随机字节,需要确定性的测试由此改用这个函数 [Go Authors, 2026f]。表中各接口的引入版本与 api/go1.24.txt 至 api/go1.26.txt 的记录一致。

16.4.7 小结

Go 的测试建立在几条命名约定之上:go test 依据约定发现测试,生成一个 main 包把它们组织成测试二进制。testing 包刻意保持朴素,只提供报告原语,把表达失败的责任留给测试作者,并以表驱动测试摊薄这一成本;子测试让表中的每一行可以单独运行、并行执行;模糊测试把覆盖率引导的输入生成纳入同一套约定,找到的反例以语料文件的形式成为回归用例。

这一设计也有放弃的东西。断言、模拟对象等便利留给了第三方库或样板代码,测试的发现规则也无法重新定义;换来的是所有 Go 项目以同样的方式组织与运行测试,持续集成、覆盖率工具与编辑器都只需面对一套约定。这与 Go 在其他方面的取舍一致(1.1):以一套人人理解、工具都能识别的简单约定,换取整个生态协作上的低摩擦。

延伸阅读