在 Go 源码里打开 net/http 这个目录,列出来是这样的:
$ ls -p $(go env GOROOT)/src/net/http | head -20alpn_test.goasync_test.gocgi/client.goclient_test.goclientconn.goclientconn_test.goclientserver_test.goclone.gocookie.gocookie_test.gocookiejar/csrf.gocsrf_test.godoc.goexample_filesystem_test.goexample_handle_test.goexample_test.goexport_test.gofcgi/这只是前 20 行。整个目录有 77 个 .go 文件,38 个以 _test.go 结尾,和代码按字母顺序交错排在一起。中间还夹着一个名字很奇怪的 export_test.go,往下翻还有一个叫 testdata/ 的目录。
第一眼的感觉是乱。写过 Java 的人习惯 src/main 和 src/test 两棵树分开,Python 项目也常有一个 tests/ 目录。Go 为什么非要把测试和代码摆在一起?能不能挪出去?Kubernetes 这种几千个测试文件的项目,难道也这么摆?要起数据库的集成测试、要起整个集群的端到端测试,又放在哪里?
本文从这个目录出发,先看 Go 自己为什么这样设计,再到 15 个大型 Go 项目里看它们实际怎么做。
一、测试文件在构建中的位置
现象:测试代码看起来混进了产品代码
先做一个小项目,后面所有实验都用它。shortlink 是一个短链接服务,三个包:
slug:把自增 ID 编成短字符串。导出Encode和Valid,内部有一个未导出的校验函数checksum;store:保存链接,import了slug;cmd/server:程序入口。
给 slug 写两个测试文件:slug_test.go 声明 package slug,测 checksum;example_test.go 声明 package slug_test,写一个示例 ExampleEncode。现在 slug/ 目录里有三个文件,和 net/http 一样,代码和测试混在一起。
问题是:测试代码会不会被编进产品?
分析:go build 看不见 _test.go
go list 可以看到 go 工具怎么给这个目录的文件分类:
$ go list -f 'GoFiles={{.GoFiles}} TestGoFiles={{.TestGoFiles}} XTestGoFiles={{.XTestGoFiles}}' ./slugGoFiles=[slug.go] TestGoFiles=[slug_test.go] XTestGoFiles=[example_test.go]同一个目录被分成了三组。go build 只用第一组;go test 才会把后两组加进来1。验证一下:加测试文件之前和之后各编一次 cmd/server,两个二进制的 sha256 完全相同,都是 eeb392905a59…。
再用 go tool nm 看符号。go test -c 产出的 slug.test 里有 Encode、Valid、checksum、TestChecksumStable、TestRoundTrip,还有一个 slug_test.ExampleEncode;产品二进制 server 里只有 Encode 和 checksum,连 Valid 都因为没被用到被裁掉了。
测试二进制具体是怎么拼出来的,go list -test -deps 列得很清楚:
$ go list -test -deps ./slug | grep shortlinkexample.com/shortlink/slugexample.com/shortlink/slug [example.com/shortlink/slug.test]example.com/shortlink/store [example.com/shortlink/slug.test]example.com/shortlink/slug_test [example.com/shortlink/slug.test]example.com/shortlink/slug.test(这里的 store 是因为后面第三节的实验让测试 import 了它,先留意它也出现在了列表里。)
图 1 把这件事画了出来。go test ./slug 做的事情是:把 slug.go 和 slug_test.go 编成一个“带测试的 slug”,再把依赖它的包(这里是 store)针对这个版本重新编译一份,把 example_test.go 编成另一个包 slug_test,最后生成一个 main 把所有 Test、Example 函数串起来。每个包各得到一个这样的测试二进制1。
所以“混在一起”只发生在目录列表里,产物是分开的。既然只是看着乱,为什么不干脆把测试挪到单独的目录?
二、包与目录的关系
第一次尝试:把测试挪进 tests/
照 Java 的习惯,在项目根目录建一个 tests/,把 slug_test.go 搬进去。包名只能改成 tests,引用 checksum 时写成 slug.checksum:
$ go test ./...# example.com/shortlink/tests [example.com/shortlink/tests.test]tests/slug_test.go:10:17: undefined: slug.checksum? example.com/shortlink/slug [no test files]FAIL example.com/shortlink/tests [build failed]未导出的函数看不见了。那只测导出的 Encode 和 Valid 呢?能编译,也能通过,但覆盖率变成了这样:
$ go test -cover ./... example.com/shortlink/slug coverage: 0.0% of statementsok example.com/shortlink/tests 0.014s coverage: [no statements]
$ go test -cover -coverpkg=./slug ./tests/ok example.com/shortlink/tests 0.006s coverage: 93.3% of statements in ./slugslug 显示 0%,tests 显示“没有语句”,要手动加 -coverpkg 才能看到真实的 93.3%。而测试放在原处时,go test -cover ./slug 直接就是 93.3%。另一个小麻烦是,改完 slug 在它的目录里跑 go test .,得到的是 [no test files]。
原因:Go 的包就是一个目录
为什么 Java 可以分开,Go 不行?关键在“包”是什么。
Java 的 package net.twwy.slug 是一个命名空间,src/main 和 src/test 两个目录里声明同一个包名的类就属于同一个包,包级私有的方法互相可见。Go 没有这一层:一个目录就是一个包,可见性以包为界,首字母小写的标识符只在这个目录里可见。测试想看到 checksum,就必须和它在同一个目录、声明同一个包名。
Rust 的选择很接近:单元测试写在源文件里的 #[cfg(test)] mod tests 中,只有跨模块的集成测试才放到 tests/ 目录。两种语言都是“能看内部的测试贴着代码,只看公开接口的测试才可以离开”。
所以在 Go 里,单元测试贴着包放不是风格偏好,而是语言的可见性规则决定的。下一节的统计会看到,15 个大型项目没有一个把单元测试搬出去。
至于“看着乱”,可以交给编辑器。VS Code 的文件嵌套功能在设置里加一条 "*.go": "${capture}_test.go",slug_test.go 就会折叠到 slug.go 下面2;GoLand 在 Project 工具窗口的设置菜单里打开 File Nesting,加一条父后缀 .go、子后缀 _test.go 的规则,效果一样3。
单元测试的位置定下来了。不过前面 example_test.go 声明的是 package slug_test,和 slug_test.go 不是同一个包。同一个目录里,为什么允许两种测试包名?
三、同目录下的两种测试包
内部测试与外部测试
Go 允许测试文件声明两种包名1:
package slug:内部测试,和代码是同一个包,能访问未导出的东西,适合测细节,比如checksum;package slug_test:外部测试,被编译成另一个包,只能像普通调用方一样import "example.com/shortlink/slug",只看得到导出的 API。示例函数通常写在这里,因为文档里展示的就是调用方写的代码。
外部测试看起来只是“自愿少看一点”,它真正解决的是另一个问题。
现象:测试里 import 一个依赖自己的包
store 用 slug 生成短串。现在想在 slug 的测试里确认:store 生成的 100 个短串都能通过 Valid。写成内部测试:
$ go test ./slug/package example.com/shortlink/slug imports example.com/shortlink/store from store_int_test.go imports example.com/shortlink/slug from store.go: import cycle not allowed in testFAIL example.com/shortlink/slug [setup failed]测试文件属于 slug 包,slug 又 import 了 store,而 store import 了 slug,形成了环。只把第一行改成 package slug_test(调用改成 slug.Valid),就通过了:
=== RUN TestKeysFromStoreAreValid--- PASS: TestKeysFromStoreAreValid (0.00s)ok example.com/shortlink/slug 0.005s图 3 左边是环的来历。右边的 slug_test 是另一个包,它和 store 一样都是 slug 的使用者,依赖关系是一棵树。第一节 go list -test -deps 里那行 store [slug.test] 就是这样来的:store 被针对带测试的 slug 重新编译了一份,再交给 slug_test 使用。
标准库里这种情况很常见:net/http 的测试要用 net/http/httptest 起测试服务器,而 httptest 本身 import 了 net/http,所以凡是要用 httptest 的 net/http 测试,都只能写成 package http_test。
export_test.go:给外部测试开一扇小门
外部测试偶尔也想看一眼内部。net/http 的做法是 export_test.go,它的开头注释写得很直白:“Bridge package to expose http internals to tests in the http_test package.”4 文件声明 package http(内部),内容是一串赋值,把未导出的东西换个导出的名字:
var ( DefaultUserAgent = defaultUserAgent NewLoggingConn = newLoggingConn ExportServerNewConn = (*Server).newConn ...)在 slug 上照做:export_test.go 里写 var Checksum = checksum。外部测试里调 slug.Checksum("3") 通过了;而在 cmd/server 里调同一个名字:
$ go build ./cmd/servercmd/server/main.go:12:46: undefined: slug.Checksum因为文件名以 _test.go 结尾,这扇门只在测试构建里存在,产品代码和别的包永远看不到它。Go 仓库里有 79 个这样的文件。
两种测试包各占多少,不同项目差别很大。Go 仓库自己 46.8% 的测试文件是外部测试,CockroachDB 是 32.6%,Hugo 是 34.2%,Kubernetes 只有 4.4%,MinIO 不到 1%。net/http 正好一半一半:19 个 package http,19 个 package http_test。
单元测试的去处到这里就清楚了:贴着包,按需要选内部或外部包名。可是集成测试要起数据库,端到端测试要起整个集群,它们也贴着包放吗?
四、集成测试的位置与门控
15 个仓库的统计
先看实际情况。下面统计了 15 个大型 Go 仓库(排除 vendor、third_party、testdata)。“在独立测试目录”指路径里有一段叫 test、tests、integration、e2e*、integrationtest*、acceptance、roachtest 之类名字的测试文件,是个近似口径。
| 仓库 | _test.go | 在独立测试目录 | 外部测试包 | 集成 / e2e 测试去处 |
|---|---|---|---|---|
| golang/go | 1905 | 3.2% | 46.8% | test/ 下 3473 个编译器测试程序;cmd/go 的 951 个脚本 |
| Kubernetes | 3572 | 24.7% | 4.4% | test/integration、test/e2e 等 |
| etcd | 419 | 44.9% | 16.0% | tests/,独立的 Go 模块 |
| Prometheus | 290 | 0% | 4.8% | 都在包里,-short 跳过慢的 |
| CockroachDB | 3179 | 4.4% | 32.6% | 包内起进程内集群;roachtest 上真集群 |
| moby | 844 | 30.0% | 4.6% | integration/(旧的 integration-cli/ 已废弃) |
| Terraform | 694 | 5.0% | 1.7% | 包内,TF_ACC 环境变量打开 |
| TiDB | 2032 | 16.0% | 29.5% | tests/ 与包内 test 子目录 |
| containerd | 356 | 22.2% | 2.0% | integration/ |
| Grafana | 2478 | 13.4% | 6.1% | 包内,TestIntegration 前缀;pkg/tests |
| Consul | 905 | 3.6% | 4.0% | 包内,-short 跳过 |
| Istio | 767 | 23.5% | 11.6% | tests/integration,integ 构建标签 |
| Hugo | 395 | 0% | 34.2% | 包内,txtar 脚本 |
| Gitea | 1070 | 23.6% | 10.4% | tests/integration、tests/e2e |
| MinIO | 247 | 0% | 0.8% | 包内 |
Go 仓库那 3.2% 主要是 cmd/compile/internal/test 这个名叫 test 的包;它真正的“独立测试目录”是根目录的 test/,里面不是 _test.go,没计入。
这张表说明两件事。第一,没有一个项目把单元测试搬出去,搬出去的只有集成测试和端到端测试。moby 的测试规范写得最明白:单元测试“located in the package they test”,API 集成测试放在 ./integration/<component>5。第二,即使是集成测试,也有一半的项目留在包里(Prometheus、Consul、Terraform、MinIO 一个都没搬)。
所以集成测试真正要回答的不是“放哪个目录”,而是:怎么让 go test ./... 默认不跑它? 集成测试慢,要外部依赖,开发时每保存一次就跑一遍是受不了的。
门控的六种方式
各项目用的办法可以归成六种:
| 方式 | 代表 | 裸跑 go test ./... 时 | 代价 |
|---|---|---|---|
| 按目录,在脚本里排除 | Kubernetes、moby、Gitea | 会跑,靠 make test 排除 | 规则在 Makefile 里;k8s 的 test.sh 用正则去掉 test/integration 等路径 |
| 构建标签 | Istio 的 integ,etcd 的 e2e、integration | 不编译 | 改代码时,带标签的文件不参与编译检查 |
testing.Short() | Consul(258 个文件)、Prometheus | 会跑,加 -short 才跳过 | 默认是全跑,日常要记得加 -short |
名字前缀 + -run | Grafana 的 1492 个 TestIntegration* | 会跑,配合 -short 跳过 | 靠约定;Grafana 用辅助函数检查名字前缀 |
| 环境变量 | Terraform 的 TF_ACC | 跳过 | 跳过是静默的,CI 忘了设就等于没测 |
| 独立模块 | etcd 的 tests/ | 不进入 | 每个模块要分别跑,需要 replace |
表中的 -short 是 go test 自带的开关,测试里用 testing.Short() 判断后调用 t.Skip6。Kubernetes 的 make test 用一组正则把 test/e2e、test/e2e_node、test/integration 等路径排除在外,集成测试由 make test-integration 单独跑,而且默认带 -short=true7。Grafana 的组合值得多看一眼:SkipIntegrationTestInShortMode(t) 先检查测试名是不是以 TestIntegration 开头,不是就直接 t.Fatal,然后 -short 时跳过。Makefile 里单元测试带 -short,集成测试用 -run "^TestIntegration"8。名字约定于是被代码强制执行了。Terraform 的验收测试会在云上创建真实资源,所以默认必须跳过,代码里写着“TF_ACC must be set to run acceptance tests, as they provision real resources”9。
Istio 是构建标签的典型用户,tests/integration 下 146 个文件都以 //go:build integ 开头10。表里唯一标成红色的是构建标签,它看起来最干净,问题出在哪?
构建标签的盲区
做个实验。给 store 写一个带 //go:build integration 的集成测试,里面调用 s.Get(k)。然后有人在生产代码里把 Get 改名成 Lookup,只改了生产代码:
$ go build ./... && go vet ./... && go test ./...? example.com/shortlink/cmd/server [no test files]ok example.com/shortlink/slug 0.005s? example.com/shortlink/store [no test files]exit=0
$ go vet -tags integration ./...vet: store/store_integration_test.go:14:16: s.Get undefined (type *store.Store has no field or method Get)exit=1编译、vet、测试三道检查全部通过,store 甚至显示 [no test files]。坏掉的集成测试要等到有人带上标签才会被发现。编辑器也一样:gopls 默认不带标签,打开这个文件时找不到它所属的包,跳转和补全都失效。
把同一个测试改成用 testing.Short() 门控:
func TestPutGetIntegration(t *testing.T) { if testing.Short() { t.Skip("集成测试,-short 时跳过") } ...}同样的改名,go test -short ./... 立刻报 s.Get undefined,store [build failed]。修好以后,-short 下它只是一行 --- SKIP。测试代码每次都被编译,只是不被执行。
用构建标签的项目也意识到了这个问题。etcd 的 tests/common 里有一个 unit_test.go,标签是 //go:build !(e2e || integration),专门在不带标签时提供一份默认实现,注释写着:没配标签时“IDE may complain “Unresolved reference ‘WithAuth’””,所以要定义一个默认的11。
在 etcd 的 tests/ 里还藏着第六种方式:它是一个独立的 Go 模块。为了测试单独建一个模块,图的是什么?
五、测试依赖与独立模块
现象:只在测试里用的库,进了别人的依赖图
继续用 shortlink 做实验。slug 的一个测试用了 testify 的 require,go mod tidy 以后 go.mod 里多了 require github.com/stretchr/testify v1.11.1。这很正常,测试依赖也要记录。
接着建一个下游项目 app,它 import 了 shortlink/slug,自己也用 testify,但想固定在旧版本 v1.8.0:
$ go get github.com/stretchr/testify@v1.8.0go: removed example.com/shortlink v0.0.0go: downgraded github.com/stretchr/testify v1.11.1 => v1.8.0
$ go mod tidy && go list -m github.com/stretchr/testifygithub.com/stretchr/testify v1.11.1go get 发现 shortlink 要求 testify 至少 v1.11.1,降级就只能把 shortlink 整个移除;tidy 把 shortlink 加回来以后,testify 又回到了 v1.11.1。app 用到的只是 slug.Encode,shortlink 的测试一行都不会进 app 的二进制,但测试的依赖实实在在地参与了 app 的版本选择。
原因:依赖图按模块算,不按包算
Go 的最小版本选择(MVS)以模块为单位:每个依赖模块 go.mod 里的直接 require 都进入依赖图12。go.mod 不区分“这条依赖是给测试用的”,所以测试依赖和产品依赖一样,会向下游传递版本下限。
etcd 是一个仓库里放了十几个模块的项目,client/v3、api、server 都会被别人 import。它的 robustness 测试要用 porcupine 做线性一致性检查,还接了 antithesis 的故障注入 SDK。这些依赖都只写在 tests/go.mod 里,server/go.mod、client/v3/go.mod、api/go.mod 里一个都没有13。tests/go.mod 用 replace 指向同仓库的 ../server、../client/v3 等目录,测试的永远是同一次提交的代码。
单独成模块还有一个副作用:./... 不会进入含 go.mod 的子目录14,所以在 etcd 根目录 go test ./... 根本碰不到 tests/,这本身就是一道门。代价是每个模块要分别跑,etcd 的 scripts/test.sh 里 unit_pass 用 run_for_all_workspace_modules 带 -short 遍历所有模块,集成和 e2e 再各自进 tests/ 跑15。
这个办法在小项目里不必要,testify 这样的小库进 go.mod 无伤大雅。它针对的是“会被很多人 import 的库,测试却要拖一堆重型依赖”的情况。
集成测试的问题解决了。etcd 的 tests/ 里除了集成测试,还有 e2e 和 robustness。集成测试和端到端测试到底差在哪?
六、端到端测试与被测对象的距离
同一份测试,两种跑法
etcd 的 tests/common 给了一个很好的对照:同一个 TestKVPut,按构建标签选不同的“集群实现”11。
实测(三个子用例:无 TLS、手动 TLS、自动 TLS):
$ go test -c -tags integration ./common && ./common.test -test.run 'TestKVPut$' -test.v--- PASS: TestKVPut (0.72s) --- PASS: TestKVPut/NoTLS (0.21s) --- PASS: TestKVPut/PeerTLS_and_ClientTLS (0.20s) --- PASS: TestKVPut/PeerAutoTLS_and_ClientAutoTLS (0.30s)
$ go test -c -tags e2e ./common && ./common.test -test.run 'TestKVPut$' -test.vINFO spawning process {"args": ["/.../etcd/bin/etcd", "--name=TestKVPutNoTLS-test-0", ...--- PASS: TestKVPut (5.51s) --- PASS: TestKVPut/NoTLS (0.80s) --- PASS: TestKVPut/PeerTLS_and_ClientTLS (0.99s) --- PASS: TestKVPut/PeerAutoTLS_and_ClientAutoTLS (3.71s)断言一模一样,差别只在集群在哪。集成模式下,etcd 的服务端代码和测试编进同一个二进制(38 MB),竞态检测、覆盖率、断点都能直接用;e2e 模式下测试二进制只是一个客户端,真正的服务端是事先 make build 编好的 bin/etcd(这一步花了 1 分 21 秒),测的是用户实际拿到的那个程序:命令行参数解析、进程启动、证书文件读取都走一遍,慢了 7 倍多。etcd 的脚本里也写着:e2e 跑的是预先编好的二进制,--race、-cover 这些参数对它不起作用15。
所以集成测试和 e2e 测试的区别,可以理解成被测对象离测试进程有多远。
从进程内到真实集群
图 8 按这个距离排了四级,每级举了真实项目的例子。
第一、二级通常叫集成测试。 Kubernetes 的 138 个集成测试目录里,有 107 个的 main_test.go 只有一行 framework.EtcdMain(m.Run)。它先 exec.LookPath("etcd") 找到本机的 etcd 起一个真进程,再在测试进程里启动 apiserver 和控制器,测完用 goleak 检查有没有遗留的 goroutine16。etcd 不好塞进测试进程(它是另一个项目),apiserver 是自己的代码,放进来才方便调试,这就是它停在第二级的原因。
第三级开始是 e2e。 Prometheus 有一个很巧的办法:cmd/prometheus/main_test.go 的 TestMain 检查命令行,看到 -test.main 就直接调用 main();测试用 exec.Command(os.Args[0], "-test.main", "--config.file=...") 把测试二进制自己当成 Prometheus 服务端拉起来17。这样不需要事先编译,被测程序和测试永远是同一份代码,而且这些测试在 -short 时跳过。
第四级的测试本身是一个独立程序。 Kubernetes 的 test/e2e 下有 513 个普通 .go 文件,只有 33 个 _test.go。测试用例写在普通包里,再由 e2e_test.go 用一长串空导入拼起来18:
import ( _ "k8s.io/kubernetes/test/e2e/apimachinery" _ "k8s.io/kubernetes/test/e2e/apps" _ "k8s.io/kubernetes/test/e2e/network" _ "k8s.io/kubernetes/test/e2e/storage" ...)go test -c 编出一个 e2e.test,带到任何能访问集群的机器上,用 ginkgo 的标签选择要跑的用例。CockroachDB 的 roachtest 也一样,pkg/cmd/roachtest/tests 下 237 个文件都是普通 .go,编成 roachtest 程序,在云上开真实集群跑 nightly19。
为什么 e2e 用例要写在普通 .go 里?试一下就知道:在 slug/helper_test.go 里定义一个 MustEncode,再在 store 的测试里调用它:
$ go test ./storestore/store_test.go:10:36: undefined: slug.MustEncode_test.go 里的东西只属于那个包的测试二进制,别的包永远 import 不到。几百个 e2e 用例要按领域分包、再拼进一个二进制,就只能写成普通包。这条规则在下面讲测试替身时还会出现。
测试离被测对象越远,输入和期望输出也越大:一份配置文件、一段 SQL、一整个站点。这些测试数据放在哪?
七、测试数据:testdata、golden 文件与数据驱动
testdata 目录
开头 net/http 里那个 testdata/ 是 go 工具的约定:名叫 testdata 的目录被完全忽略1,./... 也不会进入它14。在 slug/testdata/broken.go 里写一行根本不是 Go 的东西,go build ./... && go vet ./... 照样通过。所以 testdata 里可以放任何东西,包括故意写错的 Go 文件(Go 仓库的编译器测试就大量这么用)。
go test 运行测试时,工作目录就是包的目录,所以测试里直接写 os.ReadFile("testdata/xxx") 就能读到。数据和读它的测试在同一个目录,一起移动、一起删除。
golden 文件:把期望输出存成文件
输出很长时,把期望值写在测试代码里很难维护。常见做法是 golden 文件:期望输出存在 testdata/*.golden 里,加一个 -update 开关,开关打开时用实际输出重写文件。给 slug 写一个:
var update = flag.Bool("update", false, "用当前输出重写 testdata 里的 golden 文件")
func TestEncodeGolden(t *testing.T) { var b strings.Builder for _, id := range []uint64{0, 1, 30, 31, 961, 1 << 40} { fmt.Fprintf(&b, "%d\t%s\n", id, Encode(id)) } golden := filepath.Join("testdata", "encode.golden") if *update { os.WriteFile(golden, []byte(b.String()), 0o644) } want, _ := os.ReadFile(golden) if got := b.String(); got != string(want) { t.Errorf("输出和 %s 不一致\n--- got\n%s--- want\n%s", golden, got, want) }}第一次运行因为文件不存在而失败,go test ./slug -update 生成它,之后 golden 文件和代码一起提交,评审时能直接看到输出变了什么。
它能抓到什么普通测试抓不到的东西?做个实验:把 checksum 里的乘数从 7 改成 11,相当于有人“优化”了校验算法。
| 测试 | 结果 | 为什么 |
|---|---|---|
TestRoundTrip | PASS | 编码和校验用的是同一个新算法,仍然自洽 |
TestChecksumStable | PASS | 只测了单字符 "3",乘数不起作用 |
TestEncodeGolden | FAIL | 31 → 23b 变成 23f;961 → 223w 变成 223c |
ExampleEncode | FAIL | want 53z,got 53h |
两个“自洽”的测试都通过了,但已经发出去的短链接全部失效。只有把旧输出存下来的测试发现了这件事。
Kubernetes 把这个思路用在了 API 上。staging/src/k8s.io/api/testdata/HEAD/ 里有 579 个序列化好的 API 对象(json、yaml、protobuf 三种格式),每次发布 1.x.0 之后,把 HEAD 复制一份成 v1.34.0、v1.35.0 这样的目录。测试保证当前代码还能把每个旧版本的数据解码成相同的对象20。
数据驱动:测试用例本身就是文件
再往前一步,连“调什么函数、输入是什么”也写进文件,Go 代码只负责读文件、执行、比对。Russ Cox 在 GopherCon Australia 2023 的演讲里专门讲过这种写法:把测试用例写成文本文件,比写成 Go 的表格更容易增加21。
Prometheus 的 PromQL 测试是一个完整的例子。promql/promqltest/testdata/ 下 22 个 .test 文件,是一门小小的测试语言22:
load 5m http_requests{job="api-server", instance="0", group="production"} 0+10x10 http_requests{job="api-server", instance="1", group="production"} 0+20x10 ...
# Simple sum.eval instant at 50m SUM BY (group) (http_requests{job="api-server"}) {group="canary"} 700 {group="production"} 300load 灌入时间序列,eval 执行一条查询并列出期望结果。Go 那边只有一行:
func TestEvaluations(t *testing.T) { promqltest.RunBuiltinTests(t, newTestEngine(t))}实测 go test ./promql -run TestEvaluations 用 6.44 秒跑了 2434 个子测试。把第 17 行的 700 改成 701,失败信息是 expected 701 for {group="canary"} but got 700,子测试名里带着 aggregators.test/line_16,可以直接回到文件里改。加一个 PromQL 函数的测试,只需要在 .test 文件里加几行,不用写 Go。
CockroachDB 是这种写法用得最多的项目。它的 datadriven 库规定了一种通用格式:命令和输入在上面,---- 下面是期望输出,--rewrite 用实际输出改写文件23。pkg/ 下的 testdata 里有 5990 个文件,其中 4693 个是这种格式。SQL 逻辑测试更进一步:pkg/sql/logictest/testdata/logic_test/ 下 610 个 SQL 文件,由生成器按 26 种集群配置(单节点、三节点多租户、五节点等)生成 generated_test.go,一共 5764 个测试函数24。同一个 SQL 文件在 26 种拓扑上各跑一遍,这在手写的 Go 测试里很难做到。
各项目的测试数据格式汇总如下:
| 项目 | 文件 | 规模 | 更新期望输出 |
|---|---|---|---|
| Go 编译器 | test/*.go,首行 // run、// errorcheck 等指令 | 3473 个 | 手写;go test cmd/internal/testdir 驱动 |
| go 命令 | cmd/go/testdata/script/*.txt(txtar 脚本) | 951 个 | 手写 |
| Prometheus | promqltest/testdata/*.test | 22 个 / 2433 条 | 手写 |
| CockroachDB | datadriven 文件 | 4693 个 | --rewrite |
| logictest SQL 文件 | 610 个 → 5764 个测试 | ||
| Kubernetes | api/testdata/HEAD 与各版本快照 | 579 个 / 版本 | 每次发版复制 HEAD |
| TiDB | tests/integrationtest 的 t/*.test 与 r/*.result | 264 个 | run-tests.sh -r |
| Hugo | testscripts/*.txt;hugolib 里内联的 txtar 站点 | 80 个 + 418 处 | 手写 |
| Gitea | models/fixtures/*.yml,一张表一个文件 | 78 张表 | 手写,测试前灌进数据库 |
txtar 是 Go 自己的一种纯文本归档格式:用 “-- 文件名 --” 分隔多个文件,一段测试脚本连同它要用的所有文件写在一个 .txt 里。
Go 仓库自己是这种写法最早的使用者:根目录 test/ 下的编译器测试程序靠首行指令声明“应该能运行”还是“应该在第几行报什么错”25,go 命令的 951 个脚本测试把命令、期望输出和所需文件一起写进 txtar26。Hugo 用的是从 cmd/go 抽出来的 testscript 库27,Gitea 的 fixtures 则是最朴素的一种:一张表一个 YAML 文件,测试开始前灌进数据库28。
这些文件大多放在被测包的 testdata/ 里,因为它们就是这个包的测试的输入,跟着包走最自然。例外是 Go 编译器的 test/、Hugo 的 testscripts/、TiDB 的 tests/integrationtest 和 Gitea 的 models/fixtures:它们测的是整个程序或整个数据库,不属于某一个包,于是和集成测试一样离开了包。
测试数据解决了输入和期望输出。可被测代码往往还依赖数据库、外部 API、另一个服务,这些依赖在测试里拿什么代替?
八、依赖替身:真实实例、fake 与 mock
各项目用什么替身
Go 社区常说“不要用 mock 框架,写个 fake 就好”。实际统计一下各项目的测试文件:
| 仓库 | type Fake… | gomock | testify/mock | testcontainers |
|---|---|---|---|---|
| golang/go | 28 | 0 | 0 | 0 |
| Kubernetes | 808 | 0 | 20 | 0 |
| etcd | 35 | 0 | 0 | 0 |
| Prometheus | 27 | 0 | 2 | 0 |
| CockroachDB | 78 | 37 | 31 | 0 |
| TiDB | 37 | 85 | 18 | 0 |
| Grafana | 448 | 25 | 362 | 1 |
| Consul | 36 | 0 | 180 | 19 |
| moby、containerd、Hugo、Gitea、MinIO | 0–31 | 都是 0 | ||
gomock、testify/mock 按 import 它们的 .go 文件数统计;type Fake… 按类型声明数统计。
把各项目的做法放在一起,能看出一个大致的优先顺序。
能起真的,就起真的。 第六节那四级距离里,第一级其实就是“替身”的最佳形态:net/http/httptest 起一个真的 HTTP 服务器,etcd 的集成模式起一个真的 etcd 集群,CockroachDB 的 testcluster 起真的数据库节点,只是都在测试进程里。Kubernetes 集成测试用的也是真 etcd。TiDB 用 unistore(mockstore)在进程内模拟 TiKV,名字叫 mock,实际是一个能存能取的完整存储引擎。容器化的依赖(testcontainers)反倒很少见,15 个仓库里只有 Consul 用得多一点。
起不了的,写 fake。 fake 是一个能工作的简化实现。Kubernetes 的 808 个 Fake 类型里最典型的是 client-go 的假 clientset:由 client-gen 从 API 定义生成,内部用一个内存里的对象存储响应增删改查,183 个测试文件直接 fake.NewSimpleClientset(...) 或 fake.NewClientset(...)29。moby 的测试规范直接建议:“Consider accepting unexported interfaces instead of structs so that fakes can be provided for dependencies.”5
mock 框架是第三选择。 gomock、testify/mock 生成的对象记录“期望哪个方法被以什么参数调用几次”。它适合依赖非常多、又只关心调用协议的场景,Grafana 的服务层就是这样。代价是测试和实现的调用顺序绑在一起,重构时测试会大面积失败。
TiDB 还有一个少见的办法:failpoint。源码里写 failpoint.Inject("name", func() {...}) 这样的标记,平时是空操作;make ut 先执行 failpoint-enable,把这些标记改写成可以从测试里触发的代码,跑完再还原30。它用来在真实代码路径上注入故障,606 个文件用了它。
共享的测试工具必须是普通包
替身写好了,往往很多包都要用。第六节的实验已经说明:_test.go 里的东西别的包 import 不到。所以所有被共享的测试工具都是普通 .go 文件组成的包:Go 标准库的 net/http/httptest、internal/testenv,Kubernetes 的 client-go/kubernetes/fake,CockroachDB 的 pkg/testutils 下几十个子包。它们会出现在模块里,但只要产品代码不 import,就不会进产品二进制,第一节的实验已经验证过链接器的行为。
共享工具一多,第三节的 import cycle 又回来了。CockroachDB 几乎每个包的测试都要起一个测试服务器,可服务器的实现 pkg/server import 了几乎所有包。它的解法是让工具包只持有一个“工厂”变量:
test_server_shim.go 的注释说得很清楚:这些接口让测试“set up test servers without importing the server package (avoiding circular dependencies)”31。CockroachDB 的 311 个 TestMain 里有 284 个做的就是这件事,再加上 randutil.SeedForTests() 固定随机种子。这是第三节外部测试包思路的放大版:真正“看得见一切”的代码只出现在测试二进制的最外层。
九、回到开头:一个项目可以怎么摆
回到开头的 net/http。现在再看那个目录:_test.go 和代码交错,是因为 Go 的包就是目录,单元测试必须在里面;export_test.go 是给外部测试包开的门;testdata/ 是 go 工具约定的数据目录。它看起来乱,但每个文件的位置都由规则决定,不需要额外的约定。
把各项目的做法合在一起,一个中等规模的 Go 项目可以这样摆:
几条取舍的理由,都来自前面的实验:
- 集成测试优先用
testing.Short()门控,它保证测试代码每次都被编译;用构建标签的话,要像 etcd 那样给不带标签的情况提供一份桩,并在 CI 里专门带标签跑一次go vet。 - 外部测试包按需用:写示例、测试需要 import 使用者时用
_test包,测内部细节时用同名包。 - 需要共享的测试工具放普通包(
internal/testutil这样的位置,外部无法 import),不要指望别的包能用到你_test.go里的函数。 - 输出复杂、需要防止兼容性破坏的地方,用 golden 文件加
-update;被测对象是一门语言或一个命令时,考虑把用例写成文件。 - e2e 测试的重型依赖开始影响下游时,再把它拆成独立模块。
小结
_test.go和代码放在一起,会不会混进产品? 不会。go build只看GoFiles,加不加测试文件,产品二进制的 sha256 都一样;go test为每个包临时拼一个测试二进制。- 为什么不能像 Java 一样挪进 tests/ 目录? 因为 Go 的包就是目录,挪出去就是另一个包,看不到未导出的标识符,覆盖率也算错。“看着乱”可以用编辑器的文件嵌套解决。
- 同一个目录里为什么有两种测试包?
package slug_test是另一个包,能同时 import 被测包和它的使用者而不成环;export_test.go只在测试构建里把内部借给它。 - 集成测试放哪? 15 个项目的单元测试全部贴着包,离开包的只有集成和 e2e。关键是门控:目录、构建标签、
-short、名字前缀、环境变量、独立模块各有代价,构建标签会让坏掉的测试代码通过所有默认检查。 - 测试依赖为什么要单独一个模块?
go.mod不区分测试依赖,它们照样约束下游的版本选择;etcd 把 porcupine 等重型依赖关进tests/go.mod。 - e2e 和集成测试差在哪? 被测对象离测试进程的距离。etcd 同一个测试,进程内 0.72 秒,拉起真二进制 5.51 秒;Kubernetes 的 e2e 用例写在 513 个普通
.go里,因为_test.go不能被别的包 import。 - 测试数据放哪? 各包的
testdata/。golden 文件能发现“自洽”测试发现不了的兼容性破坏;Prometheus、CockroachDB 把用例本身写成文件,Go 代码只是解释器。 - 依赖用什么替? 能起真的就起真的,其次是 fake,mock 框架排最后;共享的替身必须是普通包,循环依赖用 TestMain 注入工厂解决。
参考资料
Footnotes
-
go help test:Test packagespkg.go.dev ↗go test 重新编译每个包并加上 *_test.go;包名带 _test 后缀的测试文件编译成单独的包;go 工具忽略名为 testdata 的目录。https://pkg.go.dev/cmd/go#hdr-Test_packages ↩ ↩2 ↩3 ↩4
-
Explorer file nestingcode.visualstudio.com ↗explorer.fileNesting.patterns 里写 "*.go": "${capture}_test.go"。https://code.visualstudio.com/docs/getstarted/userinterface#_explorer-file-nesting ↩
-
File Nesting dialogjetbrains.com ↗Project 工具窗口的设置菜单里打开,规则是“父后缀 + 子后缀”,可以自己加 .go → _test.go。https://www.jetbrains.com/help/go/file-nesting-dialog.html ↩
-
golang/go:src/net/http/export_test.gogithub.com ↗“Bridge package to expose http internals to tests in the http_test package.”https://github.com/golang/go/blob/0d37007fda071631fbcb520b99c85a8e4c93108a/src/net/http/export_test.go ↩
-
moby:TESTING.mdgithub.com ↗单元测试 located in the package they test;API 集成测试在 ./integration/
;integration-cli 已废弃。 https://github.com/moby/moby/blob/b711d21792db6db488de976eb3c4a653c92c3bc3/TESTING.md ↩ ↩2 -
Package testingpkg.go.dev ↗testing.Short、TestMain、Example 函数的 Output 注释、子测试。https://pkg.go.dev/testing ↩
-
kubernetes:hack/make-rules/test.shgithub.com ↗make test 用正则按路径排除 test/e2e、test/e2e_node、test/integration 等目录。https://github.com/kubernetes/kubernetes/blob/765dd1d145814464051056c0eff14b7357eace82/hack/make-rules/test.sh ↩
-
grafana:Makefile(test-go-unit / test-go-integration)github.com ↗单元测试带 -short;集成测试用 -run "^TestIntegration",SkipIntegrationTestInShortMode 要求测试名以 TestIntegration 开头。https://github.com/grafana/grafana/blob/cdd0e0a52b51ebfb78603dc4e4cfb36f07b0a39c/Makefile ↩
-
terraform:internal/backend/remote-state/gcs/backend_test.gogithub.com ↗“TF_ACC must be set to run acceptance tests, as they provision real resources”。https://github.com/hashicorp/terraform/blob/55988303ba5651e41e2c9097e208468c8eb3da4b/internal/backend/remote-state/gcs/backend_test.go ↩
-
istio:tests/integrationgithub.com ↗146 个文件以 //go:build integ 开头。https://github.com/istio/istio/tree/64b84bc308d5533e26f71ab7241e2c3eb927d67e/tests/integration ↩
-
etcd:tests/commongithub.com ↗同一份测试,按构建标签选 IntegrationTestRunner 或 E2eTestRunner;unit_test.go 是不带标签时的桩。https://github.com/etcd-io/etcd/tree/f3221acd10232a9f6ef0c36438517bdbc918ea26/tests/common ↩ ↩2
-
Go Modules Reference:Module graph pruninggo.dev ↗go 1.17 以后的模块,依赖图里保留每个依赖模块 go.mod 的直接 require,测试用的依赖也在其中。https://go.dev/ref/mod#graph-pruning ↩
-
etcd:tests/go.modgithub.com ↗独立模块 go.etcd.io/etcd/tests/v3,用 replace 指向同仓库的 server、client 等模块。https://github.com/etcd-io/etcd/blob/f3221acd10232a9f6ef0c36438517bdbc918ea26/tests/go.mod ↩
-
go help packages:Package lists and patternspkg.go.dev ↗./... 不包括 vendor、testdata、以 _ 或 . 开头的目录,以及含 go.mod 的子目录。https://pkg.go.dev/cmd/go#hdr-Package_lists_and_patterns ↩ ↩2
-
etcd:scripts/test.shgithub.com ↗unit_pass 带 -short 跑所有模块;integration_pass、e2e_pass、robustness_pass 各跑 tests/ 下对应目录。https://github.com/etcd-io/etcd/blob/f3221acd10232a9f6ef0c36438517bdbc918ea26/scripts/test.sh ↩ ↩2
-
kubernetes:test/integration/framework/etcd.gogithub.com ↗EtcdMain:exec.LookPath("etcd") 起一个真实 etcd 进程,并用 goleak 检查泄漏的 goroutine。https://github.com/kubernetes/kubernetes/blob/765dd1d145814464051056c0eff14b7357eace82/test/integration/framework/etcd.go ↩
-
prometheus:cmd/prometheus/main_test.gogithub.com ↗TestMain 看到 -test.main 参数就调 main(),测试用 exec.Command(os.Args[0], "-test.main", ...) 把自己当服务端拉起来。https://github.com/prometheus/prometheus/blob/a2fda0ef4638518a6a6fb27fc48be88fbd732f4b/cmd/prometheus/main_test.go ↩
-
kubernetes:test/e2e/e2e_test.gogithub.com ↗一长串空导入把 apps、network、storage 等普通包拼进一个 e2e.test 二进制。https://github.com/kubernetes/kubernetes/blob/765dd1d145814464051056c0eff14b7357eace82/test/e2e/e2e_test.go ↩
-
cockroach:pkg/cmd/roachtestgithub.com ↗tests/ 下 237 个文件,普通 .go,编成 roachtest 程序在真实云集群上跑。https://github.com/cockroachdb/cockroach/tree/d30c905fff79ef825adc96bcc647f1872a90f2ff/pkg/cmd/roachtest ↩
-
kubernetes:staging/src/k8s.io/api/testdatagithub.com ↗HEAD 目录 579 个序列化对象,每次 1.x.0 发布后复制一份成 v1.34.0 等目录,保证新代码还能读旧数据。https://github.com/kubernetes/kubernetes/tree/765dd1d145814464051056c0eff14b7357eace82/staging/src/k8s.io/api/testdata ↩
-
Go Testing By Exampleresearch.swtch.com ↗GopherCon Australia 2023 演讲的文字版:把测试用例写成文本文件、txtar、-update 重写期望输出。https://research.swtch.com/testing ↩
-
prometheus:promql/promqltest/testdatagithub.com ↗22 个 .test 文件、2433 条 eval,Go 侧只有 promqltest.RunBuiltinTests 一行。https://github.com/prometheus/prometheus/tree/a2fda0ef4638518a6a6fb27fc48be88fbd732f4b/promql/promqltest/testdata ↩
-
cockroachdb/datadrivengithub.com ↗测试文件里写命令、输入,---- 下面写期望输出;--rewrite 用实际输出改写文件。https://github.com/cockroachdb/datadriven ↩
-
cockroach:pkg/sql/logictestgithub.com ↗testdata/logic_test 下 610 个 SQL 测试文件,按 26 种集群配置生成 generated_test.go,共 5764 个测试函数。https://github.com/cockroachdb/cockroach/tree/d30c905fff79ef825adc96bcc647f1872a90f2ff/pkg/sql/logictest ↩
-
golang/go:test/ 目录与 cmd/internal/testdirgithub.com ↗3473 个给编译器的测试程序,首行 // run、// errorcheck、// compile 等指令,由 go test cmd/internal/testdir 驱动。https://github.com/golang/go/tree/0d37007fda071631fbcb520b99c85a8e4c93108a/test ↩
-
golang/go:src/cmd/go/testdata/scriptgithub.com ↗951 个 txtar 格式的脚本测试,go test cmd/go -run=Script/^foo$ 单独跑一个。https://github.com/golang/go/tree/0d37007fda071631fbcb520b99c85a8e4c93108a/src/cmd/go/testdata/script ↩
-
hugo:testscriptsgithub.com ↗80 个 txtar 脚本测试 hugo 命令行;hugolib 里还有 418 处把整个站点写成 txtar 字符串的集成测试。https://github.com/gohugoio/hugo/tree/f63482bfbfe6aab81aaa198d9a006c7047f08d89/testscripts ↩
-
gitea:models/fixturesgithub.com ↗78 个 YAML 文件,每个文件是一张表的测试数据,测试前灌进 sqlite / MySQL / PostgreSQL。https://github.com/go-gitea/gitea/tree/ff68b39c93d7b0d10e0f6629fb188f8f1c0b37d4/models/fixtures ↩
-
kubernetes:client-go/kubernetes/fakegithub.com ↗client-gen 生成的假 clientset,是普通包,别的仓库的测试也能 import。https://github.com/kubernetes/kubernetes/tree/765dd1d145814464051056c0eff14b7357eace82/staging/src/k8s.io/client-go/kubernetes/fake ↩
-
pingcap/failpointgithub.com ↗TiDB 的 make ut 先 failpoint-enable,把源码里的 failpoint 标记改写成可触发的代码,跑完再还原。https://github.com/pingcap/failpoint ↩
-
cockroach:pkg/testutils/serverutils/test_server_shim.gogithub.com ↗“allow tests to set up test servers without importing the server package (avoiding circular dependencies)”,由 TestMain 注入 server.TestServerFactory。https://github.com/cockroachdb/cockroach/blob/d30c905fff79ef825adc96bcc647f1872a90f2ff/pkg/testutils/serverutils/test_server_shim.go ↩
延伸阅读
- Advanced Testing with Gospeakerdeck.com ↗HashiCorp 的测试经验:testdata、golden 文件和 -update 开关、用子进程测试。https://speakerdeck.com/mitchellh/advanced-testing-with-go
- uber-go/goleakgithub.com ↗在 TestMain 或测试结束时检查没退出的 goroutine,k8s、Prometheus、TiDB 都在用。https://github.com/uber-go/goleak
- rogpeppe/go-internal:testscriptpkg.go.dev ↗从 cmd/go 的脚本测试抽出来的独立库,hugo 用的就是它。https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript