贴着代码的测试,和越走越远的测试 左边是一个包目录的文件列表,slug.go 下面紧跟着 slug_test.go,另有 testdata 目录,下方一条 go test -short 命令。右边从左到右依次是:一个包,里面每个代码文件背后都贴着一张强调色的测试文件;一个数据库圆柱;一个由三个节点组成的集群。三者之间用越来越长的虚线连接,表示测试离被测对象越来越远。 slug/ slug.go slug_test.go example_test.go testdata/ $ go test -short package = 目录

在 Go 源码里打开 net/http 这个目录,列出来是这样的:

$ ls -p $(go env GOROOT)/src/net/http | head -20
alpn_test.go
async_test.go
cgi/
client.go
client_test.go
clientconn.go
clientconn_test.go
clientserver_test.go
clone.go
cookie.go
cookie_test.go
cookiejar/
csrf.go
csrf_test.go
doc.go
example_filesystem_test.go
example_handle_test.go
example_test.go
export_test.go
fcgi/

这只是前 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}}' ./slug
GoFiles=[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 shortlink
example.com/shortlink/slug
example.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 了它,先留意它也出现在了列表里。)

同一个目录,两种构建 左边是 slug 目录里的三个文件:slug.go、slug_test.go(package slug)和 example_test.go(package slug_test),以及另一个目录的 store.go。右上是 go build ./cmd/server 的产物,只用了 slug.go,加不加测试文件 sha256 都是 eeb392905a59。右下是 go test ./slug 的产物 slug.test,由四部分组成:带测试文件的 slug、重新编译的 store、单独的 slug_test 包和 go 生成的 main。 源码 · slug/ go build ./cmd/server → server go test ./slug → slug.test slug.go package slug slug_test.go package slug example_test.go package slug_test 另一个目录 store/store.go import ".../slug" slug_test.go 只走向下面那条路 slug 只有 slug.go:Encode、checksum store · main 和平时一样 sha256 eeb392905a59…(加测试文件前后相同) slug [slug.test] slug.go + slug_test.go store [slug.test] 依赖 slug,重新编译一份 slug_test [slug.test] example_test.go,另一个包 slug.test go 生成的 main,逐个调用测试
图 1 · 同一个目录里的文件被分成两种构建:产品二进制里没有任何测试代码,测试二进制是 go test 临时拼出来的

图 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 statements
ok 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 ./slug

slug 显示 0%,tests 显示“没有语句”,要手动加 -coverpkg 才能看到真实的 93.3%。而测试放在原处时,go test -cover ./slug 直接就是 93.3%。另一个小麻烦是,改完 slug 在它的目录里跑 go test .,得到的是 [no test files]。

原因:Go 的包就是一个目录

为什么 Java 可以分开,Go 不行?关键在“包”是什么。

Java 的包是命名空间,Go 的包是目录 左边是 Java:src/main/java/net/twwy/slug 和 src/test/java/net/twwy/slug 两个目录都声明 package net.twwy.slug,所以测试能调用包级私有的 checksum。右边是 Go:slug 目录是 package slug,tests 目录只能是另一个包 package tests,调用 slug.checksum 报 undefined;覆盖率显示 slug 0.0%、tests 没有语句,加 -coverpkg 才是 93.3%。 Java · Maven Go 调用 checksum() ✓ src/main/java/net/twwy/slug/ Slug.java · int checksum(String s) src/test/java/net/twwy/slug/ SlugTest.java package net.twwy.slug 虚线框:两个目录声明同一个包名, 包是命名空间,目录只是存放位置 undefined: slug.checksum slug/ → package slug slug.go · func checksum(s string) int tests/ → package tests slug_test.go $ go test -cover ./... slug coverage: 0.0% tests coverage: [no statements] 加 -coverpkg=./slug 才显示 93.3%
图 2 · 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。写成内部测试:

example.com/shortlink/slug
$ 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 test
FAIL 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
内部测试成环,外部测试不成环 左边:store_int_test.go 属于 slug 包,它 import store,而 store import slug,形成 import cycle not allowed in test。右边:同一个测试改成 package slug_test,它站在 slug 和 store 外面,分别 import 两者,store 仍然 import slug,没有环;slug 包里的 export_test.go 把 checksum 以 Checksum 的名字借给 slug_test,只在测试构建中存在。 内部测试:package slug 外部测试:package slug_test import slug slug.go store_int_test.go store store.go import cycle not allowed in test 测试文件属于 slug 包,slug 经由 store 又 import 了自己 slug.Valid store.New slug_test store_int_test.go slug slug.go export_test.go store store.go 虚线:只在测试构建里存在,借出 Checksum
图 3 · 外部测试包站在被测包和它的使用者外面,所以测试可以同时 import 两者而不成环

图 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(内部),内容是一串赋值,把未导出的东西换个导出的名字:

Go
var (
DefaultUserAgent = defaultUserAgent
NewLoggingConn = newLoggingConn
ExportServerNewConn = (*Server).newConn
...
)

在 slug 上照做:export_test.go 里写 var Checksum = checksum。外部测试里调 slug.Checksum("3") 通过了;而在 cmd/server 里调同一个名字:

$ go build ./cmd/server
cmd/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 之类名字的测试文件,是个近似口径。

15 个仓库的 _test.go 分布(2026-10-24 前后的默认分支)
仓库_test.go在独立测试目录外部测试包集成 / e2e 测试去处
golang/go19053.2%46.8%test/ 下 3473 个编译器测试程序;cmd/go 的 951 个脚本
Kubernetes357224.7%4.4%test/integration、test/e2e 等
etcd41944.9%16.0%tests/,独立的 Go 模块
Prometheus2900%4.8%都在包里,-short 跳过慢的
CockroachDB31794.4%32.6%包内起进程内集群;roachtest 上真集群
moby84430.0%4.6%integration/(旧的 integration-cli/ 已废弃)
Terraform6945.0%1.7%包内,TF_ACC 环境变量打开
TiDB203216.0%29.5%tests/ 与包内 test 子目录
containerd35622.2%2.0%integration/
Grafana247813.4%6.1%包内,TestIntegration 前缀;pkg/tests
Consul9053.6%4.0%包内,-short 跳过
Istio76723.5%11.6%tests/integration,integ 构建标签
Hugo3950%34.2%包内,txtar 脚本
Gitea107023.6%10.4%tests/integration、tests/e2e
MinIO2470%0.8%包内

Go 仓库那 3.2% 主要是 cmd/compile/internal/test 这个名叫 test 的包;它真正的“独立测试目录”是根目录的 test/,里面不是 _test.go,没计入。

图 4 · 单元测试在所有项目里都贴着包;离开包的那部分(最多不到一半)都是集成测试和 e2e 测试

这张表说明两件事。第一,没有一个项目把单元测试搬出去,搬出去的只有集成测试和端到端测试。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
名字前缀 + -runGrafana 的 1492 个 TestIntegration*会跑,配合 -short 跳过靠约定;Grafana 用辅助函数检查名字前缀
环境变量Terraform 的 TF_ACC跳过跳过是静默的,CI 忘了设就等于没测
独立模块etcd 的 tests/不进入每个模块要分别跑,需要 replace
图 5 · 六种门控方式的区别在于“裸跑时会怎样”和“慢测试的代码还有没有人编译”

表中的 -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() 门控:

Go
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.0
go: removed example.com/shortlink v0.0.0
go: downgraded github.com/stretchr/testify v1.11.1 => v1.8.0
$ go mod tidy && go list -m github.com/stretchr/testify
github.com/stretchr/testify v1.11.1

go 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 把它关在 tests 模块里 左边:shortlink 的 go.mod 因为一个测试 require testify v1.11.1,下游 app 想用 v1.8.0,go get 移除了 shortlink,tidy 后 testify 又回到 v1.11.1。右边:etcd 的 server/go.mod 有 68 行依赖,没有 porcupine 和 antithesis;tests/go.mod 有 92 行依赖,包括 porcupine v1.3.1 和 antithesis-sdk-go v0.8.0,并用 replace 指向 ../server;根目录 go test ./... 不进入含 go.mod 的 tests 目录。 一个只在测试里用的依赖 etcd:测试单独一个模块 版本下限传给下游 shortlink/go.mod require testify v1.11.1 只有 slug/assert_test.go 用到 app/go.mod $ go get testify@v1.8.0 removed example.com/shortlink $ go mod tidy testify 回到 v1.11.1 replace => ../server server/go.mod 68 行依赖 porcupine ✗ · antithesis ✗ tests/go.mod 92 行依赖,其中 porcupine v1.3.1 antithesis-sdk-go v0.8.0 根目录 go test ./... 遇到含 go.mod 的 tests/ 就停下
图 6 · go.mod 不区分测试依赖,它们照样约束下游的版本;etcd 把重型测试依赖关进单独的 tests 模块,server、client 的 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。

etcd tests/common:一份测试,标签决定跑在哪 上方是 kv_test.go 里的 TestKVPut,通过 testRunner.NewCluster 建集群,再 Put 和 Get。下方三栏:不带标签时 unit_test.go 生效,测试直接退出并提示选择模式;-tags integration 时 integration_test.go 把 testRunner 设为 IntegrationTestRunner,在测试进程内起集群,三个子用例共 0.72 秒;-tags e2e 时 e2e_test.go 设为 E2eTestRunner,拉起 bin/etcd 进程,共 5.51 秒。 tests/common · go.etcd.io/etcd/tests/v3 kv_test.go · TestKVPut clus := testRunner.NewCluster(ctx, t, cfg) cc.Put(ctx, "foo", "bar") cc.Get(ctx, "foo") 不带标签 unit_test.go UnitTestRunner “No test mode selected” 直接退出 -tags integration integration_test.go IntegrationTestRunner 集群成员在测试进程里 0.72 s -tags e2e e2e_test.go E2eTestRunner 拉起 bin/etcd 子进程 5.51 s
图 7 · 测试代码只依赖 testRunner 这个接口,标签决定集群是测试进程里的对象还是真的 etcd 进程

实测(三个子用例:无 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.v
INFO 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 测试的区别,可以理解成被测对象离测试进程有多远。

从进程内到真实集群

被测对象离测试进程越来越远的四级 四行。第一级,被测对象是测试进程里的对象:httptest.NewServer、etcd 集成模式、CockroachDB 的 testcluster。第二级,测试进程加真实依赖进程:Kubernetes test/integration 用 EtcdMain 起真实 etcd 进程,apiserver 在测试进程里。第三级,拉起被测程序本身:etcd e2e 拉起 bin/etcd,Prometheus 用 os.Args[0] -test.main 把测试二进制自己当服务端。第四级,对外部真实集群跑:Kubernetes 的 e2e.test 由 513 个普通 .go 文件拼成,CockroachDB 的 roachtest 有 237 个测试文件在云上集群跑。 更远 ① 进程内对象 go test 直接跑 ② 加真实依赖进程 本机装好依赖 ③ 拉起被测程序 先编出二进制 ④ 外部真实集群 单独的测试程序 httptest.NewServer net/http 自带的测试服务器 etcd integration · testcluster etcd、CockroachDB 的进程内集群 framework.EtcdMain(m.Run) Kubernetes test/integration:起一个真 etcd 进程,apiserver 在测试进程里 bin/etcd × 3 etcd e2e:事先 make build os.Args[0] -test.main Prometheus:测试二进制当服务端 e2e.test Kubernetes:513 个普通 .go ginkgo,对已有集群跑 roachtest CockroachDB:237 个测试文件 在云上建集群跑
图 8 · 从上到下,被测对象离测试进程越来越远:越往下越接近用户真实看到的系统,也越慢、越难调试

图 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:

Go
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 ./store
store/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 写一个:

Go
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,相当于有人“优化”了校验算法。

checksum 的乘数从 7 改成 11 之后
测试结果为什么
TestRoundTripPASS编码和校验用的是同一个新算法,仍然自洽
TestChecksumStablePASS只测了单字符 "3",乘数不起作用
TestEncodeGoldenFAIL31 → 23b 变成 23f;961 → 223w 变成 223c
ExampleEncodeFAILwant 53z,got 53h

两个“自洽”的测试都通过了,但已经发出去的短链接全部失效。只有把旧输出存下来的测试发现了这件事。

图 9 · golden 文件和 Example 的 Output 注释记住的是“过去的输出”,所以能发现兼容性破坏

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"} 300

load 灌入时间序列,eval 执行一条查询并列出期望结果。Go 那边只有一行:

Go
func TestEvaluations(t *testing.T) {
promqltest.RunBuiltinTests(t, newTestEngine(t))
}
数据驱动测试的循环 左边是 aggregators.test 文件第 16 到 18 行:eval instant at 50m SUM BY (group),期望 canary 为 700,这里被改成 701。中间是 promql_test.go 里只有一行的 TestEvaluations,它把 22 个文件解析成 2433 条 eval,每条一个子测试。右边是结果:子测试名带文件名和行号 line_16,报错 expected 701 for group canary but got 700。底部一条虚线从结果指回文件,表示 CockroachDB 的 --rewrite 和 golden 文件的 -update 用实际输出写回文件。 Prometheus · go test ./promql -run TestEvaluations aggregators.test 16 eval instant at 50m SUM BY (group) (...) 17 {group="canary"} 701 18 {group="production"} 300 promql_test.go RunBuiltinTests(t, eng) 22 个文件 → 2433 条 eval → 2434 个子测试 FAIL .../aggregators.test/line_16/ SUM_BY_(group)_(...) expected 701 for {group="canary"} but got 700 虚线:-update / --rewrite 用实际输出写回文件(golden、CockroachDB 的 datadriven)
图 10 · 用例和期望值都在文件里,Go 代码只是一个解释器;失败信息直接指回文件的行号

实测 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 个手写
Prometheuspromqltest/testdata/*.test22 个 / 2433 条手写
CockroachDBdatadriven 文件4693 个--rewrite
logictest SQL 文件610 个 → 5764 个测试
Kubernetesapi/testdata/HEAD 与各版本快照579 个 / 版本每次发版复制 HEAD
TiDBtests/integrationtest 的 t/*.test 与 r/*.result264 个run-tests.sh -r
Hugotestscripts/*.txt;hugolib 里内联的 txtar 站点80 个 + 418 处手写
Giteamodels/fixtures/*.yml,一张表一个文件78 张表手写,测试前灌进数据库

txtar 是 Go 自己的一种纯文本归档格式:用 “-- 文件名 --” 分隔多个文件,一段测试脚本连同它要用的所有文件写在一个 .txt 里。

图 11 · 被测对象越像“一门语言”或“一个命令”(SQL、PromQL、go 命令、hugo 命令),测试越适合写成文件

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…gomocktestify/mocktestcontainers
golang/go28000
Kubernetes8080200
etcd35000
Prometheus27020
CockroachDB7837310
TiDB3785180
Grafana448253621
Consul36018019
moby、containerd、Hugo、Gitea、MinIO0–31都是 0

gomock、testify/mock 按 import 它们的 .go 文件数统计;type Fake… 按类型声明数统计。

图 12 · 多数项目靠手写或生成的 fake;mock 框架只在 Grafana、Consul、TiDB 这样的项目里大量出现,Go 仓库自己一个都不用

把各项目的做法放在一起,能看出一个大致的优先顺序。

能起真的,就起真的。 第六节那四级距离里,第一级其实就是“替身”的最佳形态: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 了几乎所有包。它的解法是让工具包只持有一个“工厂”变量:

CockroachDB 用 TestMain 注入测试服务器工厂 pkg/server import 了 pkg/kv/kvserver。pkg/testutils/serverutils 不 import server,只持有一个工厂变量并提供 StartServer。kvserver 的外部测试包里 main_test.go 同时 import server 和 serverutils,在 TestMain 中调用 InitTestServerFactory(server.TestServerFactory),把工厂注入 serverutils;之后 kvserver 的测试调用 serverutils.StartServer 就能起一个完整的测试服务器。 CockroachDB · 用 TestMain 注入,绕开循环依赖 注入工厂 取出工厂 pkg/server TestServerFactory 几乎 import 了所有包 pkg/kv/kvserver replica.go … 被 server import testutils/serverutils StartServer(t, args) 不 import server kvserver_test · main_test.go InitTestServerFactory( server.TestServerFactory)
图 13 · 工具包只存一个工厂变量,真正的实现由外部测试包的 TestMain 塞进去,依赖关系里没有环

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 项目可以这样摆:

一个中等规模项目的测试布局 左边是目录树:slug 目录里有 slug.go、slug_test.go(package slug)、example_test.go(package slug_test)、需要时才加的 export_test.go 和 testdata/encode.golden;store 目录里有 store.go 和用 -short 跳过的集成测试 store_test.go;internal/testutil 放共享的测试工具,是普通 .go;e2e 目录放拉起 server 的测试,依赖重时做成独立模块。右边是三层命令:每次保存跑 go test -short ./...,提交前和 CI 跑 go test -race ./...,合并前或 nightly 进 e2e 目录跑。 目录 什么时候跑 shortlink/ ├─ go.mod ├─ slug/ │ ├─ slug.go │ ├─ slug_test.go package slug,测 checksum │ ├─ example_test.go package slug_test │ ├─ export_test.go 需要时才加 │ └─ testdata/encode.golden ├─ store/ │ ├─ store.go │ └─ store_test.go 集成测试,-short 时跳过 ├─ internal/testutil/ 共享的测试工具,普通 .go ├─ cmd/server/main.go └─ e2e/ 拉起 server;依赖重就单独成模块 每次保存 go test -short ./... 单元测试,秒级 提交前 · CI go test -race ./... 加上集成测试 合并前 · nightly go build ./cmd/server cd e2e && go test ./... 对编好的二进制跑
图 14 · 单元测试和测试数据贴着包;慢的测试用 -short 门控而不是构建标签;只有真正拉起程序的 e2e 才离开包

几条取舍的理由,都来自前面的实验:

  • 集成测试优先用 testing.Short() 门控,它保证测试代码每次都被编译;用构建标签的话,要像 etcd 那样给不带标签的情况提供一份桩,并在 CI 里专门带标签跑一次 go vet。
  • 外部测试包按需用:写示例、测试需要 import 使用者时用 _test 包,测内部细节时用同名包。
  • 需要共享的测试工具放普通包(internal/testutil 这样的位置,外部无法 import),不要指望别的包能用到你 _test.go 里的函数。
  • 输出复杂、需要防止兼容性破坏的地方,用 golden 文件加 -update;被测对象是一门语言或一个命令时,考虑把用例写成文件。
  • e2e 测试的重型依赖开始影响下游时,再把它拆成独立模块。

小结

  1. _test.go 和代码放在一起,会不会混进产品? 不会。go build 只看 GoFiles,加不加测试文件,产品二进制的 sha256 都一样;go test 为每个包临时拼一个测试二进制。
  2. 为什么不能像 Java 一样挪进 tests/ 目录? 因为 Go 的包就是目录,挪出去就是另一个包,看不到未导出的标识符,覆盖率也算错。“看着乱”可以用编辑器的文件嵌套解决。
  3. 同一个目录里为什么有两种测试包? package slug_test 是另一个包,能同时 import 被测包和它的使用者而不成环;export_test.go 只在测试构建里把内部借给它。
  4. 集成测试放哪? 15 个项目的单元测试全部贴着包,离开包的只有集成和 e2e。关键是门控:目录、构建标签、-short、名字前缀、环境变量、独立模块各有代价,构建标签会让坏掉的测试代码通过所有默认检查。
  5. 测试依赖为什么要单独一个模块? go.mod 不区分测试依赖,它们照样约束下游的版本选择;etcd 把 porcupine 等重型依赖关进 tests/go.mod。
  6. e2e 和集成测试差在哪? 被测对象离测试进程的距离。etcd 同一个测试,进程内 0.72 秒,拉起真二进制 5.51 秒;Kubernetes 的 e2e 用例写在 513 个普通 .go 里,因为 _test.go 不能被别的包 import。
  7. 测试数据放哪? 各包的 testdata/。golden 文件能发现“自洽”测试发现不了的兼容性破坏;Prometheus、CockroachDB 把用例本身写成文件,Go 代码只是解释器。
  8. 依赖用什么替? 能起真的就起真的,其次是 fake,mock 框架排最后;共享的替身必须是普通包,循环依赖用 TestMain 注入工厂解决。

参考资料

Footnotes

  1. go help test:Test packagesGo 文档pkg.go.dev ↗go test 重新编译每个包并加上 *_test.go;包名带 _test 后缀的测试文件编译成单独的包;go 工具忽略名为 testdata 的目录。https://pkg.go.dev/cmd/go#hdr-Test_packages ↩ ↩2 ↩3 ↩4

  2. Explorer file nestingVS Code 文档code.visualstudio.com ↗explorer.fileNesting.patterns 里写 "*.go": "${capture}_test.go"。https://code.visualstudio.com/docs/getstarted/userinterface#_explorer-file-nesting ↩

  3. File Nesting dialogGoLand 文档jetbrains.com ↗Project 工具窗口的设置菜单里打开,规则是“父后缀 + 子后缀”,可以自己加 .go → _test.go。https://www.jetbrains.com/help/go/file-nesting-dialog.html ↩

  4. golang/go:src/net/http/export_test.goGitHubgithub.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 ↩

  5. moby:TESTING.mdGitHubgithub.com ↗单元测试 located in the package they test;API 集成测试在 ./integration/;integration-cli 已废弃。https://github.com/moby/moby/blob/b711d21792db6db488de976eb3c4a653c92c3bc3/TESTING.md ↩ ↩2

  6. Package testingGo 文档pkg.go.dev ↗testing.Short、TestMain、Example 函数的 Output 注释、子测试。https://pkg.go.dev/testing ↩

  7. kubernetes:hack/make-rules/test.shGitHubgithub.com ↗make test 用正则按路径排除 test/e2e、test/e2e_node、test/integration 等目录。https://github.com/kubernetes/kubernetes/blob/765dd1d145814464051056c0eff14b7357eace82/hack/make-rules/test.sh ↩

  8. grafana:Makefile(test-go-unit / test-go-integration)GitHubgithub.com ↗单元测试带 -short;集成测试用 -run "^TestIntegration",SkipIntegrationTestInShortMode 要求测试名以 TestIntegration 开头。https://github.com/grafana/grafana/blob/cdd0e0a52b51ebfb78603dc4e4cfb36f07b0a39c/Makefile ↩

  9. terraform:internal/backend/remote-state/gcs/backend_test.goGitHubgithub.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 ↩

  10. istio:tests/integrationGitHubgithub.com ↗146 个文件以 //go:build integ 开头。https://github.com/istio/istio/tree/64b84bc308d5533e26f71ab7241e2c3eb927d67e/tests/integration ↩

  11. etcd:tests/commonGitHubgithub.com ↗同一份测试,按构建标签选 IntegrationTestRunner 或 E2eTestRunner;unit_test.go 是不带标签时的桩。https://github.com/etcd-io/etcd/tree/f3221acd10232a9f6ef0c36438517bdbc918ea26/tests/common ↩ ↩2

  12. Go Modules Reference:Module graph pruningGo 文档go.dev ↗go 1.17 以后的模块,依赖图里保留每个依赖模块 go.mod 的直接 require,测试用的依赖也在其中。https://go.dev/ref/mod#graph-pruning ↩

  13. etcd:tests/go.modGitHubgithub.com ↗独立模块 go.etcd.io/etcd/tests/v3,用 replace 指向同仓库的 server、client 等模块。https://github.com/etcd-io/etcd/blob/f3221acd10232a9f6ef0c36438517bdbc918ea26/tests/go.mod ↩

  14. go help packages:Package lists and patternsGo 文档pkg.go.dev ↗./... 不包括 vendor、testdata、以 _ 或 . 开头的目录,以及含 go.mod 的子目录。https://pkg.go.dev/cmd/go#hdr-Package_lists_and_patterns ↩ ↩2

  15. etcd:scripts/test.shGitHubgithub.com ↗unit_pass 带 -short 跑所有模块;integration_pass、e2e_pass、robustness_pass 各跑 tests/ 下对应目录。https://github.com/etcd-io/etcd/blob/f3221acd10232a9f6ef0c36438517bdbc918ea26/scripts/test.sh ↩ ↩2

  16. kubernetes:test/integration/framework/etcd.goGitHubgithub.com ↗EtcdMain:exec.LookPath("etcd") 起一个真实 etcd 进程,并用 goleak 检查泄漏的 goroutine。https://github.com/kubernetes/kubernetes/blob/765dd1d145814464051056c0eff14b7357eace82/test/integration/framework/etcd.go ↩

  17. prometheus:cmd/prometheus/main_test.goGitHubgithub.com ↗TestMain 看到 -test.main 参数就调 main(),测试用 exec.Command(os.Args[0], "-test.main", ...) 把自己当服务端拉起来。https://github.com/prometheus/prometheus/blob/a2fda0ef4638518a6a6fb27fc48be88fbd732f4b/cmd/prometheus/main_test.go ↩

  18. kubernetes:test/e2e/e2e_test.goGitHubgithub.com ↗一长串空导入把 apps、network、storage 等普通包拼进一个 e2e.test 二进制。https://github.com/kubernetes/kubernetes/blob/765dd1d145814464051056c0eff14b7357eace82/test/e2e/e2e_test.go ↩

  19. cockroach:pkg/cmd/roachtestGitHubgithub.com ↗tests/ 下 237 个文件,普通 .go,编成 roachtest 程序在真实云集群上跑。https://github.com/cockroachdb/cockroach/tree/d30c905fff79ef825adc96bcc647f1872a90f2ff/pkg/cmd/roachtest ↩

  20. kubernetes:staging/src/k8s.io/api/testdataGitHubgithub.com ↗HEAD 目录 579 个序列化对象,每次 1.x.0 发布后复制一份成 v1.34.0 等目录,保证新代码还能读旧数据。https://github.com/kubernetes/kubernetes/tree/765dd1d145814464051056c0eff14b7357eace82/staging/src/k8s.io/api/testdata ↩

  21. Go Testing By ExampleRuss Cox · research.swtch.com · 2023-12-05research.swtch.com ↗GopherCon Australia 2023 演讲的文字版:把测试用例写成文本文件、txtar、-update 重写期望输出。https://research.swtch.com/testing ↩

  22. prometheus:promql/promqltest/testdataGitHubgithub.com ↗22 个 .test 文件、2433 条 eval,Go 侧只有 promqltest.RunBuiltinTests 一行。https://github.com/prometheus/prometheus/tree/a2fda0ef4638518a6a6fb27fc48be88fbd732f4b/promql/promqltest/testdata ↩

  23. cockroachdb/datadrivenGitHubgithub.com ↗测试文件里写命令、输入,---- 下面写期望输出;--rewrite 用实际输出改写文件。https://github.com/cockroachdb/datadriven ↩

  24. cockroach:pkg/sql/logictestGitHubgithub.com ↗testdata/logic_test 下 610 个 SQL 测试文件,按 26 种集群配置生成 generated_test.go,共 5764 个测试函数。https://github.com/cockroachdb/cockroach/tree/d30c905fff79ef825adc96bcc647f1872a90f2ff/pkg/sql/logictest ↩

  25. golang/go:test/ 目录与 cmd/internal/testdirGitHubgithub.com ↗3473 个给编译器的测试程序,首行 // run、// errorcheck、// compile 等指令,由 go test cmd/internal/testdir 驱动。https://github.com/golang/go/tree/0d37007fda071631fbcb520b99c85a8e4c93108a/test ↩

  26. golang/go:src/cmd/go/testdata/scriptGitHubgithub.com ↗951 个 txtar 格式的脚本测试,go test cmd/go -run=Script/^foo$ 单独跑一个。https://github.com/golang/go/tree/0d37007fda071631fbcb520b99c85a8e4c93108a/src/cmd/go/testdata/script ↩

  27. hugo:testscriptsGitHubgithub.com ↗80 个 txtar 脚本测试 hugo 命令行;hugolib 里还有 418 处把整个站点写成 txtar 字符串的集成测试。https://github.com/gohugoio/hugo/tree/f63482bfbfe6aab81aaa198d9a006c7047f08d89/testscripts ↩

  28. gitea:models/fixturesGitHubgithub.com ↗78 个 YAML 文件,每个文件是一张表的测试数据,测试前灌进 sqlite / MySQL / PostgreSQL。https://github.com/go-gitea/gitea/tree/ff68b39c93d7b0d10e0f6629fb188f8f1c0b37d4/models/fixtures ↩

  29. kubernetes:client-go/kubernetes/fakeGitHubgithub.com ↗client-gen 生成的假 clientset,是普通包,别的仓库的测试也能 import。https://github.com/kubernetes/kubernetes/tree/765dd1d145814464051056c0eff14b7357eace82/staging/src/k8s.io/client-go/kubernetes/fake ↩

  30. pingcap/failpointGitHubgithub.com ↗TiDB 的 make ut 先 failpoint-enable,把源码里的 failpoint 标记改写成可触发的代码,跑完再还原。https://github.com/pingcap/failpoint ↩

  31. cockroach:pkg/testutils/serverutils/test_server_shim.goGitHubgithub.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 ↩

延伸阅读