﻿---
title: "Go 的测试为什么和代码放在一起：大型工程怎么组织测试"
description: "打开 Go 标准库的 net/http，77 个文件里一半是 _test.go，和代码交错排着。这样不乱吗？顺着问题往下追：测试文件在构建里到底在哪；为什么不能像 Java 那样挪进单独的 tests 目录；同一个目录里的两种测试包；集成测试放哪、怎么不让它默认跑；测试依赖为什么要单独一个模块；e2e 测试离被测对象有多远；测试数据和依赖替身各自怎么放。统计了 Go、Kubernetes、etcd、Prometheus、CockroachDB 等 15 个仓库，并用 Go 1.27 逐个实验。"
pubDate: 2026-10-25
tags: ["Go", "测试", "工程实践"]
hero: "./hero.svg"
refs:
  - id: go-test
    title: "go help test：Test packages"
    url: https://pkg.go.dev/cmd/go#hdr-Test_packages
    site: Go 文档
    note: "go test 重新编译每个包并加上 *_test.go；包名带 _test 后缀的测试文件编译成单独的包；go 工具忽略名为 testdata 的目录。"
  - id: go-packages
    title: "go help packages：Package lists and patterns"
    url: https://pkg.go.dev/cmd/go#hdr-Package_lists_and_patterns
    site: Go 文档
    note: "./... 不包括 vendor、testdata、以 _ 或 . 开头的目录，以及含 go.mod 的子目录。"
  - id: go-testing
    title: "Package testing"
    url: https://pkg.go.dev/testing
    site: Go 文档
    note: "testing.Short、TestMain、Example 函数的 Output 注释、子测试。"
  - id: go-mod-pruning
    title: "Go Modules Reference：Module graph pruning"
    url: https://go.dev/ref/mod#graph-pruning
    site: Go 文档
    note: "go 1.17 以后的模块，依赖图里保留每个依赖模块 go.mod 的直接 require，测试用的依赖也在其中。"
  - id: go-http-export
    title: "golang/go：src/net/http/export_test.go"
    url: https://github.com/golang/go/blob/0d37007fda071631fbcb520b99c85a8e4c93108a/src/net/http/export_test.go
    site: GitHub
    note: "“Bridge package to expose http internals to tests in the http_test package.”"
  - id: go-testdir
    title: "golang/go：test/ 目录与 cmd/internal/testdir"
    url: https://github.com/golang/go/tree/0d37007fda071631fbcb520b99c85a8e4c93108a/test
    site: GitHub
    note: "3473 个给编译器的测试程序，首行 // run、// errorcheck、// compile 等指令，由 go test cmd/internal/testdir 驱动。"
  - id: go-script
    title: "golang/go：src/cmd/go/testdata/script"
    url: https://github.com/golang/go/tree/0d37007fda071631fbcb520b99c85a8e4c93108a/src/cmd/go/testdata/script
    site: GitHub
    note: "951 个 txtar 格式的脚本测试，go test cmd/go -run=Script/^foo$ 单独跑一个。"
  - id: rsc-testing
    title: "Go Testing By Example"
    url: https://research.swtch.com/testing
    author: Russ Cox
    site: research.swtch.com
    date: 2023-12-05
    note: "GopherCon Australia 2023 演讲的文字版：把测试用例写成文本文件、txtar、-update 重写期望输出。"
  - id: vscode-nesting
    title: "Explorer file nesting"
    url: https://code.visualstudio.com/docs/getstarted/userinterface#_explorer-file-nesting
    site: VS Code 文档
    note: "explorer.fileNesting.patterns 里写 \"*.go\": \"${capture}_test.go\"。"
  - id: goland-nesting
    title: "File Nesting dialog"
    url: https://www.jetbrains.com/help/go/file-nesting-dialog.html
    site: GoLand 文档
    note: "Project 工具窗口的设置菜单里打开，规则是“父后缀 + 子后缀”，可以自己加 .go → _test.go。"
  - id: k8s-test-sh
    title: "kubernetes：hack/make-rules/test.sh"
    url: https://github.com/kubernetes/kubernetes/blob/765dd1d145814464051056c0eff14b7357eace82/hack/make-rules/test.sh
    site: GitHub
    note: "make test 用正则按路径排除 test/e2e、test/e2e_node、test/integration 等目录。"
  - id: k8s-etcdmain
    title: "kubernetes：test/integration/framework/etcd.go"
    url: https://github.com/kubernetes/kubernetes/blob/765dd1d145814464051056c0eff14b7357eace82/test/integration/framework/etcd.go
    site: GitHub
    note: "EtcdMain：exec.LookPath(\"etcd\") 起一个真实 etcd 进程，并用 goleak 检查泄漏的 goroutine。"
  - id: k8s-e2e
    title: "kubernetes：test/e2e/e2e_test.go"
    url: https://github.com/kubernetes/kubernetes/blob/765dd1d145814464051056c0eff14b7357eace82/test/e2e/e2e_test.go
    site: GitHub
    note: "一长串空导入把 apps、network、storage 等普通包拼进一个 e2e.test 二进制。"
  - id: k8s-api-testdata
    title: "kubernetes：staging/src/k8s.io/api/testdata"
    url: https://github.com/kubernetes/kubernetes/tree/765dd1d145814464051056c0eff14b7357eace82/staging/src/k8s.io/api/testdata
    site: GitHub
    note: "HEAD 目录 579 个序列化对象，每次 1.x.0 发布后复制一份成 v1.34.0 等目录，保证新代码还能读旧数据。"
  - id: k8s-fake
    title: "kubernetes：client-go/kubernetes/fake"
    url: https://github.com/kubernetes/kubernetes/tree/765dd1d145814464051056c0eff14b7357eace82/staging/src/k8s.io/client-go/kubernetes/fake
    site: GitHub
    note: "client-gen 生成的假 clientset，是普通包，别的仓库的测试也能 import。"
  - id: etcd-tests
    title: "etcd：tests/go.mod"
    url: https://github.com/etcd-io/etcd/blob/f3221acd10232a9f6ef0c36438517bdbc918ea26/tests/go.mod
    site: GitHub
    note: "独立模块 go.etcd.io/etcd/tests/v3，用 replace 指向同仓库的 server、client 等模块。"
  - id: etcd-common
    title: "etcd：tests/common"
    url: https://github.com/etcd-io/etcd/tree/f3221acd10232a9f6ef0c36438517bdbc918ea26/tests/common
    site: GitHub
    note: "同一份测试，按构建标签选 IntegrationTestRunner 或 E2eTestRunner；unit_test.go 是不带标签时的桩。"
  - id: etcd-test-sh
    title: "etcd：scripts/test.sh"
    url: https://github.com/etcd-io/etcd/blob/f3221acd10232a9f6ef0c36438517bdbc918ea26/scripts/test.sh
    site: GitHub
    note: "unit_pass 带 -short 跑所有模块；integration_pass、e2e_pass、robustness_pass 各跑 tests/ 下对应目录。"
  - id: prom-promqltest
    title: "prometheus：promql/promqltest/testdata"
    url: https://github.com/prometheus/prometheus/tree/a2fda0ef4638518a6a6fb27fc48be88fbd732f4b/promql/promqltest/testdata
    site: GitHub
    note: "22 个 .test 文件、2433 条 eval，Go 侧只有 promqltest.RunBuiltinTests 一行。"
  - id: prom-main-test
    title: "prometheus：cmd/prometheus/main_test.go"
    url: https://github.com/prometheus/prometheus/blob/a2fda0ef4638518a6a6fb27fc48be88fbd732f4b/cmd/prometheus/main_test.go
    site: GitHub
    note: "TestMain 看到 -test.main 参数就调 main()，测试用 exec.Command(os.Args[0], \"-test.main\", ...) 把自己当服务端拉起来。"
  - id: crdb-datadriven
    title: "cockroachdb/datadriven"
    url: https://github.com/cockroachdb/datadriven
    site: GitHub
    note: "测试文件里写命令、输入，---- 下面写期望输出；--rewrite 用实际输出改写文件。"
  - id: crdb-logictest
    title: "cockroach：pkg/sql/logictest"
    url: https://github.com/cockroachdb/cockroach/tree/d30c905fff79ef825adc96bcc647f1872a90f2ff/pkg/sql/logictest
    site: GitHub
    note: "testdata/logic_test 下 610 个 SQL 测试文件，按 26 种集群配置生成 generated_test.go，共 5764 个测试函数。"
  - id: crdb-shim
    title: "cockroach：pkg/testutils/serverutils/test_server_shim.go"
    url: https://github.com/cockroachdb/cockroach/blob/d30c905fff79ef825adc96bcc647f1872a90f2ff/pkg/testutils/serverutils/test_server_shim.go
    site: GitHub
    note: "“allow tests to set up test servers without importing the server package (avoiding circular dependencies)”，由 TestMain 注入 server.TestServerFactory。"
  - id: crdb-roachtest
    title: "cockroach：pkg/cmd/roachtest"
    url: https://github.com/cockroachdb/cockroach/tree/d30c905fff79ef825adc96bcc647f1872a90f2ff/pkg/cmd/roachtest
    site: GitHub
    note: "tests/ 下 237 个文件，普通 .go，编成 roachtest 程序在真实云集群上跑。"
  - id: moby-testing
    title: "moby：TESTING.md"
    url: https://github.com/moby/moby/blob/b711d21792db6db488de976eb3c4a653c92c3bc3/TESTING.md
    site: GitHub
    note: "单元测试 located in the package they test；API 集成测试在 ./integration/<component>；integration-cli 已废弃。"
  - id: istio-integ
    title: "istio：tests/integration"
    url: https://github.com/istio/istio/tree/64b84bc308d5533e26f71ab7241e2c3eb927d67e/tests/integration
    site: GitHub
    note: "146 个文件以 //go:build integ 开头。"
  - id: grafana-make
    title: "grafana：Makefile（test-go-unit / test-go-integration）"
    url: https://github.com/grafana/grafana/blob/cdd0e0a52b51ebfb78603dc4e4cfb36f07b0a39c/Makefile
    site: GitHub
    note: "单元测试带 -short；集成测试用 -run \"^TestIntegration\"，SkipIntegrationTestInShortMode 要求测试名以 TestIntegration 开头。"
  - id: terraform-tfacc
    title: "terraform：internal/backend/remote-state/gcs/backend_test.go"
    url: https://github.com/hashicorp/terraform/blob/55988303ba5651e41e2c9097e208468c8eb3da4b/internal/backend/remote-state/gcs/backend_test.go
    site: GitHub
    note: "“TF_ACC must be set to run acceptance tests, as they provision real resources”。"
  - id: tidb-failpoint
    title: "pingcap/failpoint"
    url: https://github.com/pingcap/failpoint
    site: GitHub
    note: "TiDB 的 make ut 先 failpoint-enable，把源码里的 failpoint 标记改写成可触发的代码，跑完再还原。"
  - id: hugo-testscripts
    title: "hugo：testscripts"
    url: https://github.com/gohugoio/hugo/tree/f63482bfbfe6aab81aaa198d9a006c7047f08d89/testscripts
    site: GitHub
    note: "80 个 txtar 脚本测试 hugo 命令行；hugolib 里还有 418 处把整个站点写成 txtar 字符串的集成测试。"
  - id: gitea-fixtures
    title: "gitea：models/fixtures"
    url: https://github.com/go-gitea/gitea/tree/ff68b39c93d7b0d10e0f6629fb188f8f1c0b37d4/models/fixtures
    site: GitHub
    note: "78 个 YAML 文件，每个文件是一张表的测试数据，测试前灌进 sqlite / MySQL / PostgreSQL。"
  - id: hashimoto
    title: "Advanced Testing with Go"
    url: https://speakerdeck.com/mitchellh/advanced-testing-with-go
    author: Mitchell Hashimoto
    site: Speaker Deck
    note: "HashiCorp 的测试经验：testdata、golden 文件和 -update 开关、用子进程测试。"
    further: true
  - id: goleak
    title: "uber-go/goleak"
    url: https://github.com/uber-go/goleak
    site: GitHub
    note: "在 TestMain 或测试结束时检查没退出的 goroutine，k8s、Prometheus、TiDB 都在用。"
    further: true
  - id: testscript
    title: "rogpeppe/go-internal：testscript"
    url: https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript
    site: pkg.go.dev
    note: "从 cmd/go 的脚本测试抽出来的独立库，hugo 用的就是它。"
    further: true
---

在 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` 才会把后两组加进来[^go-test]。验证一下：加测试文件之前和之后各编一次 `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 了它，先留意它也出现在了列表里。）

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gtl1-title gtl1-desc">
<title id="gtl1-title">同一个目录，两种构建</title>
<desc id="gtl1-desc">左边是 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。</desc>
<defs>
<marker id="gtl1-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gtl1-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="400" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="256" height="368" rx="8"/>
<rect class="dd-zone" x="288" y="16" width="496" height="144" rx="8"/>
<rect class="dd-zone" x="288" y="176" width="496" height="208" rx="8"/>
<text class="t-zone" x="32" y="44">源码 · slug/</text>
<text class="t-zone" x="304" y="44">go build ./cmd/server → server</text>
<text class="t-zone" x="304" y="204">go test ./slug → slug.test</text>
<path class="dd-edge" d="M256 90 H296" marker-end="url(#gtl1-arrow)"/>
<path class="dd-edge-acc" d="M256 154 H276 Q284 154 284 162 V235 Q284 243 292 243 H296" marker-end="url(#gtl1-arrow-acc)"/>
<path class="dd-edge" d="M256 218 H268 Q276 218 276 226 V303 Q276 311 284 311 H296" marker-end="url(#gtl1-arrow)"/>
<rect class="dd-card" x="32" y="64" width="224" height="52" rx="6"/>
<text class="t-mono" x="48" y="86">slug.go</text>
<text class="t-note" x="48" y="106">package slug</text>
<rect class="dd-card" x="32" y="128" width="224" height="52" rx="6"/>
<text class="t-mono" x="48" y="150"><tspan class="t-acc">slug_test.go</tspan></text>
<text class="t-note" x="48" y="170">package slug</text>
<rect class="dd-card" x="32" y="192" width="224" height="52" rx="6"/>
<text class="t-mono" x="48" y="214">example_test.go</text>
<text class="t-note" x="48" y="234">package slug_test</text>
<text class="t-note" x="32" y="276">另一个目录</text>
<rect class="dd-card" x="32" y="288" width="224" height="52" rx="6"/>
<text class="t-mono" x="48" y="310">store/store.go</text>
<text class="t-note" x="48" y="330">import ".../slug"</text>
<text class="t-say" x="32" y="368">slug_test.go 只走向下面那条路</text>
<rect class="dd-card" x="304" y="60" width="464" height="84" rx="6"/>
<text class="t-mono" x="320" y="81">slug</text>
<text class="t-val" x="448" y="81">只有 slug.go：Encode、checksum</text>
<line class="dd-hair" x1="304" y1="90" x2="768" y2="90"/>
<text class="t-mono" x="320" y="107">store · main</text>
<text class="t-val" x="448" y="107">和平时一样</text>
<line class="dd-hair" x1="304" y1="116" x2="768" y2="116"/>
<text class="t-mono" x="320" y="133">sha256</text>
<text class="t-val" x="448" y="133">eeb392905a59…（加测试文件前后相同）</text>
<rect class="dd-card" x="304" y="220" width="464" height="144" rx="6"/>
<text class="t-mono" x="320" y="248"><tspan class="t-acc">slug</tspan> [slug.test]</text>
<text class="t-val" x="520" y="248">slug.go + <tspan class="t-acc">slug_test.go</tspan></text>
<line class="dd-hair" x1="304" y1="260" x2="768" y2="260"/>
<text class="t-mono" x="320" y="282">store [slug.test]</text>
<text class="t-val" x="520" y="282">依赖 slug，重新编译一份</text>
<line class="dd-hair" x1="304" y1="294" x2="768" y2="294"/>
<text class="t-mono" x="320" y="316">slug_test [slug.test]</text>
<text class="t-val" x="520" y="316">example_test.go，另一个包</text>
<line class="dd-hair" x1="304" y1="328" x2="768" y2="328"/>
<text class="t-mono" x="320" y="350">slug.test</text>
<text class="t-val" x="520" y="350">go 生成的 main，逐个调用测试</text>
</svg>
</div>
<figcaption>图 1 · 同一个目录里的文件被分成两种构建：产品二进制里没有任何测试代码，测试二进制是 go test 临时拼出来的</figcaption>
</figure>

图 1 把这件事画了出来。`go test ./slug` 做的事情是：把 `slug.go` 和 `slug_test.go` 编成一个“带测试的 slug”，再把依赖它的包（这里是 `store`）针对这个版本重新编译一份，把 `example_test.go` 编成另一个包 `slug_test`，最后生成一个 `main` 把所有 `Test`、`Example` 函数串起来。每个包各得到一个这样的测试二进制[^go-test]。

所以“混在一起”只发生在目录列表里，产物是分开的。**既然只是看着乱，为什么不干脆把测试挪到单独的目录？**

## 二、包与目录的关系

### 第一次尝试：把测试挪进 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 不行？关键在“包”是什么。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 384" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gtl2-title gtl2-desc">
<title id="gtl2-title">Java 的包是命名空间，Go 的包是目录</title>
<desc id="gtl2-desc">左边是 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%。</desc>
<defs>
<marker id="gtl2-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gtl2-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="384" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="352" rx="8"/>
<rect class="dd-zone" x="408" y="16" width="376" height="352" rx="8"/>
<text class="t-zone" x="32" y="44">Java · Maven</text>
<text class="t-zone" x="424" y="44">Go</text>
<rect class="dd-derived" x="28" y="56" width="352" height="196" rx="8"/>
<path class="dd-edge" d="M204 172 V132" marker-end="url(#gtl2-arrow)"/>
<text class="t-note" x="214" y="156">调用 checksum() ✓</text>
<rect class="dd-card" x="44" y="68" width="320" height="60" rx="6"/>
<text class="t-mono" x="60" y="92">src/main/java/net/twwy/slug/</text>
<text class="t-val" x="60" y="114">Slug.java · int checksum(String s)</text>
<rect class="dd-card" x="44" y="176" width="320" height="60" rx="6"/>
<text class="t-mono" x="60" y="200">src/test/java/net/twwy/slug/</text>
<text class="t-val" x="60" y="222">SlugTest.java</text>
<text class="t-mono" x="32" y="280"><tspan class="t-acc">package net.twwy.slug</tspan></text>
<text class="t-say" x="32" y="308">虚线框：两个目录声明同一个包名，</text>
<text class="t-say" x="32" y="328">包是命名空间，目录只是存放位置</text>
<path class="dd-edge-acc dd-dash" d="M520 172 V132" marker-end="url(#gtl2-arrow-acc)"/>
<text class="t-mono" x="530" y="156"><tspan class="t-acc">undefined: slug.checksum</tspan></text>
<rect class="dd-card" x="424" y="68" width="344" height="60" rx="6"/>
<text class="t-mono" x="440" y="92">slug/ → package slug</text>
<text class="t-val" x="440" y="114">slug.go · func checksum(s string) int</text>
<rect class="dd-card" x="424" y="176" width="344" height="60" rx="6"/>
<text class="t-mono" x="440" y="200">tests/ → package tests</text>
<text class="t-val" x="440" y="222">slug_test.go</text>
<rect class="dd-card" x="424" y="252" width="344" height="104" rx="6"/>
<text class="t-mono" x="440" y="273">$ go test -cover ./...</text>
<line class="dd-hair" x1="424" y1="282" x2="768" y2="282"/>
<text class="t-val" x="440" y="299">slug    coverage: 0.0%</text>
<line class="dd-hair" x1="424" y1="308" x2="768" y2="308"/>
<text class="t-val" x="440" y="325">tests   coverage: [no statements]</text>
<text class="t-note" x="440" y="346">加 -coverpkg=./slug 才显示 93.3%</text>
</svg>
</div>
<figcaption>图 2 · Java 的包是命名空间，可以横跨两棵目录树；Go 的包就是一个目录，测试挪出目录就成了另一个包</figcaption>
</figure>

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` 下面[^vscode-nesting]；GoLand 在 Project 工具窗口的设置菜单里打开 File Nesting，加一条父后缀 `.go`、子后缀 `_test.go` 的规则，效果一样[^goland-nesting]。

单元测试的位置定下来了。不过前面 `example_test.go` 声明的是 `package slug_test`，和 `slug_test.go` 不是同一个包。**同一个目录里，为什么允许两种测试包名？**

## 三、同目录下的两种测试包

### 内部测试与外部测试

Go 允许测试文件声明两种包名[^go-test]：

- `package slug`：**内部测试**，和代码是同一个包，能访问未导出的东西，适合测细节，比如 `checksum`；
- `package slug_test`：**外部测试**，被编译成另一个包，只能像普通调用方一样 `import "example.com/shortlink/slug"`，只看得到导出的 API。示例函数通常写在这里，因为文档里展示的就是调用方写的代码。

外部测试看起来只是“自愿少看一点”，它真正解决的是另一个问题。

### 现象：测试里 import 一个依赖自己的包

`store` 用 `slug` 生成短串。现在想在 `slug` 的测试里确认：`store` 生成的 100 个短串都能通过 `Valid`。写成内部测试：

```
$ go test ./slug/
# example.com/shortlink/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
```

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 344" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gtl3-title gtl3-desc">
<title id="gtl3-title">内部测试成环，外部测试不成环</title>
<desc id="gtl3-desc">左边：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，只在测试构建中存在。</desc>
<defs>
<marker id="gtl3-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gtl3-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="344" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="312" rx="8"/>
<rect class="dd-zone" x="408" y="16" width="376" height="312" rx="8"/>
<text class="t-zone" x="32" y="44">内部测试：package slug</text>
<text class="t-zone" x="424" y="44">外部测试：package slug_test</text>
<path class="dd-edge" d="M240 96 H200" marker-end="url(#gtl3-arrow)"/>
<text class="t-note" x="200" y="88">import</text>
<path class="dd-edge-acc" d="M192 146 H296 Q304 146 304 138 V132" marker-end="url(#gtl3-arrow-acc)"/>
<rect class="dd-card" x="40" y="64" width="152" height="100" rx="6"/>
<text class="t-name" x="56" y="88">slug</text>
<line class="dd-hair" x1="40" y1="100" x2="192" y2="100"/>
<text class="t-mono" x="56" y="120">slug.go</text>
<text class="t-mono" x="56" y="150"><tspan class="t-acc">store_int_test.go</tspan></text>
<rect class="dd-card" x="240" y="72" width="128" height="60" rx="6"/>
<text class="t-name" x="256" y="96">store</text>
<text class="t-mono" x="256" y="118">store.go</text>
<text class="t-mono" x="40" y="208"><tspan class="t-acc">import cycle not allowed in test</tspan></text>
<text class="t-say" x="40" y="240">测试文件属于 slug 包，slug 经由 store</text>
<text class="t-say" x="40" y="260">又 import 了自己</text>
<path class="dd-edge-acc" d="M508 116 V160" marker-end="url(#gtl3-arrow-acc)"/>
<path class="dd-edge-acc" d="M692 116 V184" marker-end="url(#gtl3-arrow-acc)"/>
<path class="dd-edge" d="M640 214 H592" marker-end="url(#gtl3-arrow)"/>
<text class="t-note" x="518" y="144">slug.Valid</text>
<text class="t-note" x="702" y="144">store.New</text>
<rect class="dd-card" x="432" y="64" width="320" height="52" rx="6"/>
<text class="t-name" x="448" y="86">slug_test</text>
<text class="t-mono" x="448" y="106"><tspan class="t-acc">store_int_test.go</tspan></text>
<rect class="dd-card" x="432" y="168" width="160" height="112" rx="6"/>
<text class="t-name" x="448" y="192">slug</text>
<line class="dd-hair" x1="432" y1="204" x2="592" y2="204"/>
<text class="t-mono" x="448" y="224">slug.go</text>
<rect class="dd-derived" x="440" y="236" width="144" height="36" rx="4"/>
<text class="t-mono" x="448" y="259">export_test.go</text>
<rect class="dd-card" x="640" y="184" width="112" height="60" rx="6"/>
<text class="t-name" x="656" y="208">store</text>
<text class="t-mono" x="656" y="230">store.go</text>
<text class="t-say" x="432" y="304">虚线：只在测试构建里存在，借出 Checksum</text>
</svg>
</div>
<figcaption>图 3 · 外部测试包站在被测包和它的使用者外面，所以测试可以同时 import 两者而不成环</figcaption>
</figure>

图 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.”[^go-http-export] 文件声明 `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` 之类名字的测试文件，是个近似口径。

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="gtl-t1-title">15 个仓库的 _test.go 分布（2026-10-24 前后的默认分支）</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="gtl-t1-title">
<colgroup><col style="width:16%"><col style="width:12%"><col class="hl" style="width:14%"><col style="width:12%"><col></colgroup>
<thead>
<tr><th scope="col">仓库</th><th scope="col" class="num">_test.go</th><th scope="col" class="num acc">在独立测试目录</th><th scope="col" class="num">外部测试包</th><th scope="col">集成 / e2e 测试去处</th></tr>
</thead>
<tbody>
<tr><th scope="row">golang/go</th><td class="num mono">1905</td><td class="num mono acc">3.2%</td><td class="num mono">46.8%</td><td><code>test/</code> 下 3473 个编译器测试程序；<code>cmd/go</code> 的 951 个脚本</td></tr>
<tr><th scope="row">Kubernetes</th><td class="num mono">3572</td><td class="num mono acc">24.7%</td><td class="num mono">4.4%</td><td><code>test/integration</code>、<code>test/e2e</code> 等</td></tr>
<tr><th scope="row">etcd</th><td class="num mono">419</td><td class="num mono acc">44.9%</td><td class="num mono">16.0%</td><td><code>tests/</code>，独立的 Go 模块</td></tr>
<tr><th scope="row">Prometheus</th><td class="num mono">290</td><td class="num mono acc">0%</td><td class="num mono">4.8%</td><td>都在包里，<code>-short</code> 跳过慢的</td></tr>
<tr><th scope="row">CockroachDB</th><td class="num mono">3179</td><td class="num mono acc">4.4%</td><td class="num mono">32.6%</td><td>包内起进程内集群；<code>roachtest</code> 上真集群</td></tr>
<tr><th scope="row">moby</th><td class="num mono">844</td><td class="num mono acc">30.0%</td><td class="num mono">4.6%</td><td><code>integration/</code>（旧的 <code>integration-cli/</code> 已废弃）</td></tr>
<tr><th scope="row">Terraform</th><td class="num mono">694</td><td class="num mono acc">5.0%</td><td class="num mono">1.7%</td><td>包内，<code>TF_ACC</code> 环境变量打开</td></tr>
<tr><th scope="row">TiDB</th><td class="num mono">2032</td><td class="num mono acc">16.0%</td><td class="num mono">29.5%</td><td><code>tests/</code> 与包内 <code>test</code> 子目录</td></tr>
<tr><th scope="row">containerd</th><td class="num mono">356</td><td class="num mono acc">22.2%</td><td class="num mono">2.0%</td><td><code>integration/</code></td></tr>
<tr><th scope="row">Grafana</th><td class="num mono">2478</td><td class="num mono acc">13.4%</td><td class="num mono">6.1%</td><td>包内，<code>TestIntegration</code> 前缀；<code>pkg/tests</code></td></tr>
<tr><th scope="row">Consul</th><td class="num mono">905</td><td class="num mono acc">3.6%</td><td class="num mono">4.0%</td><td>包内，<code>-short</code> 跳过</td></tr>
<tr><th scope="row">Istio</th><td class="num mono">767</td><td class="num mono acc">23.5%</td><td class="num mono">11.6%</td><td><code>tests/integration</code>，<code>integ</code> 构建标签</td></tr>
<tr><th scope="row">Hugo</th><td class="num mono">395</td><td class="num mono acc">0%</td><td class="num mono">34.2%</td><td>包内，txtar 脚本</td></tr>
<tr><th scope="row">Gitea</th><td class="num mono">1070</td><td class="num mono acc">23.6%</td><td class="num mono">10.4%</td><td><code>tests/integration</code>、<code>tests/e2e</code></td></tr>
<tr><th scope="row">MinIO</th><td class="num mono">247</td><td class="num mono acc">0%</td><td class="num mono">0.8%</td><td>包内</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">Go 仓库那 3.2% 主要是 cmd/compile/internal/test 这个名叫 test 的包；它真正的“独立测试目录”是根目录的 test/，里面不是 _test.go，没计入。</p>
</div>
<figcaption>图 4 · 单元测试在所有项目里都贴着包；离开包的那部分（最多不到一半）都是集成测试和 e2e 测试</figcaption>
</figure>

这张表说明两件事。第一，没有一个项目把单元测试搬出去，搬出去的只有集成测试和端到端测试。moby 的测试规范写得最明白：单元测试“located in the package they test”，API 集成测试放在 `./integration/<component>`[^moby-testing]。第二，即使是集成测试，也有一半的项目留在包里（Prometheus、Consul、Terraform、MinIO 一个都没搬）。

所以集成测试真正要回答的不是“放哪个目录”，而是：**怎么让 `go test ./...` 默认不跑它？** 集成测试慢，要外部依赖，开发时每保存一次就跑一遍是受不了的。

### 门控的六种方式

各项目用的办法可以归成六种：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="gtl-t2-title">让集成测试默认不跑的六种方式</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="gtl-t2-title">
<colgroup><col style="width:19%"><col style="width:22%"><col style="width:25%"><col></colgroup>
<thead>
<tr><th scope="col">方式</th><th scope="col">代表</th><th scope="col">裸跑 go test ./... 时</th><th scope="col">代价</th></tr>
</thead>
<tbody>
<tr><th scope="row">按目录，在脚本里排除</th><td>Kubernetes、moby、Gitea</td><td class="warn">会跑，靠 make test 排除</td><td>规则在 Makefile 里；k8s 的 test.sh 用正则去掉 <code>test/integration</code> 等路径</td></tr>
<tr><th scope="row">构建标签</th><td>Istio 的 <code>integ</code>，etcd 的 <code>e2e</code>、<code>integration</code></td><td class="good">不编译</td><td class="bad">改代码时，带标签的文件不参与编译检查</td></tr>
<tr><th scope="row"><code>testing.Short()</code></th><td>Consul（258 个文件）、Prometheus</td><td class="warn">会跑，加 <code>-short</code> 才跳过</td><td>默认是全跑，日常要记得加 <code>-short</code></td></tr>
<tr><th scope="row">名字前缀 + <code>-run</code></th><td>Grafana 的 1492 个 <code>TestIntegration*</code></td><td class="warn">会跑，配合 <code>-short</code> 跳过</td><td>靠约定；Grafana 用辅助函数检查名字前缀</td></tr>
<tr><th scope="row">环境变量</th><td>Terraform 的 <code>TF_ACC</code></td><td class="good">跳过</td><td>跳过是静默的，CI 忘了设就等于没测</td></tr>
<tr><th scope="row">独立模块</th><td>etcd 的 <code>tests/</code></td><td class="good">不进入</td><td>每个模块要分别跑，需要 <code>replace</code></td></tr>
</tbody>
</table>
</div>
</div>
<figcaption>图 5 · 六种门控方式的区别在于“裸跑时会怎样”和“慢测试的代码还有没有人编译”</figcaption>
</figure>

表中的 `-short` 是 go test 自带的开关，测试里用 `testing.Short()` 判断后调用 `t.Skip`[^go-testing]。Kubernetes 的 `make test` 用一组正则把 `test/e2e`、`test/e2e_node`、`test/integration` 等路径排除在外，集成测试由 `make test-integration` 单独跑，而且默认带 `-short=true`[^k8s-test-sh]。Grafana 的组合值得多看一眼：`SkipIntegrationTestInShortMode(t)` 先检查测试名是不是以 `TestIntegration` 开头，不是就直接 `t.Fatal`，然后 `-short` 时跳过。Makefile 里单元测试带 `-short`，集成测试用 `-run "^TestIntegration"`[^grafana-make]。名字约定于是被代码强制执行了。Terraform 的验收测试会在云上创建真实资源，所以默认必须跳过，代码里写着“TF_ACC must be set to run acceptance tests, as they provision real resources”[^terraform-tfacc]。

Istio 是构建标签的典型用户，`tests/integration` 下 146 个文件都以 `//go:build integ` 开头[^istio-integ]。表里唯一标成红色的是构建标签，它看起来最干净，问题出在哪？

### 构建标签的盲区

做个实验。给 `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'"”，所以要定义一个默认的[^etcd-common]。

在 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` 都进入依赖图[^go-mod-pruning]。`go.mod` 不区分“这条依赖是给测试用的”，所以测试依赖和产品依赖一样，会向下游传递版本下限。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 368" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gtl6-title gtl6-desc">
<title id="gtl6-title">测试依赖向下游传递，etcd 把它关在 tests 模块里</title>
<desc id="gtl6-desc">左边：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 目录。</desc>
<defs>
<marker id="gtl6-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gtl6-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="368" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="336" rx="8"/>
<rect class="dd-zone" x="408" y="16" width="376" height="336" rx="8"/>
<text class="t-zone" x="32" y="44">一个只在测试里用的依赖</text>
<text class="t-zone" x="424" y="44">etcd：测试单独一个模块</text>
<path class="dd-edge-acc" d="M204 146 V188" marker-end="url(#gtl6-arrow-acc)"/>
<text class="t-note" x="214" y="172">版本下限传给下游</text>
<rect class="dd-card" x="32" y="60" width="344" height="86" rx="6"/>
<path class="dd-card-head" d="M38 60 H370 Q376 60 376 66 V92 H32 V66 Q32 60 38 60 Z"/>
<text class="t-name" x="48" y="81">shortlink/go.mod</text>
<text class="t-mono" x="48" y="114">require <tspan class="t-acc">testify v1.11.1</tspan></text>
<text class="t-note" x="48" y="135">只有 slug/assert_test.go 用到</text>
<rect class="dd-card" x="32" y="192" width="344" height="144" rx="6"/>
<path class="dd-card-head" d="M38 192 H370 Q376 192 376 198 V224 H32 V198 Q32 192 38 192 Z"/>
<text class="t-name" x="48" y="213">app/go.mod</text>
<text class="t-mono" x="48" y="245">$ go get testify@v1.8.0</text>
<line class="dd-hair" x1="32" y1="254" x2="376" y2="254"/>
<text class="t-val" x="48" y="271">removed example.com/shortlink</text>
<line class="dd-hair" x1="32" y1="280" x2="376" y2="280"/>
<text class="t-mono" x="48" y="297">$ go mod tidy</text>
<line class="dd-hair" x1="32" y1="306" x2="376" y2="306"/>
<text class="t-val" x="48" y="323">testify 回到 <tspan class="t-acc">v1.11.1</tspan></text>
<path class="dd-edge dd-dash" d="M596 192 V150" marker-end="url(#gtl6-arrow)"/>
<text class="t-note" x="606" y="176">replace => ../server</text>
<rect class="dd-card" x="424" y="60" width="344" height="86" rx="6"/>
<path class="dd-card-head" d="M430 60 H762 Q768 60 768 66 V92 H424 V66 Q424 60 430 60 Z"/>
<text class="t-name" x="440" y="81">server/go.mod</text>
<text class="t-val" x="440" y="114">68 行依赖</text>
<text class="t-val" x="440" y="135">porcupine ✗ · antithesis ✗</text>
<rect class="dd-card" x="424" y="192" width="344" height="112" rx="6"/>
<path class="dd-card-head" d="M430 192 H762 Q768 192 768 198 V224 H424 V198 Q424 192 430 192 Z"/>
<text class="t-name" x="440" y="213">tests/go.mod</text>
<text class="t-val" x="440" y="245">92 行依赖，其中</text>
<line class="dd-hair" x1="424" y1="254" x2="768" y2="254"/>
<text class="t-mono" x="440" y="271"><tspan class="t-acc">porcupine v1.3.1</tspan></text>
<line class="dd-hair" x1="424" y1="280" x2="768" y2="280"/>
<text class="t-mono" x="440" y="297"><tspan class="t-acc">antithesis-sdk-go v0.8.0</tspan></text>
<text class="t-say" x="424" y="326">根目录 go test ./... 遇到含 go.mod 的</text>
<text class="t-say" x="424" y="344">tests/ 就停下</text>
</svg>
</div>
<figcaption>图 6 · go.mod 不区分测试依赖，它们照样约束下游的版本；etcd 把重型测试依赖关进单独的 tests 模块，server、client 的 go.mod 保持干净</figcaption>
</figure>

etcd 是一个仓库里放了十几个模块的项目，`client/v3`、`api`、`server` 都会被别人 import。它的 robustness 测试要用 porcupine 做线性一致性检查，还接了 antithesis 的故障注入 SDK。这些依赖都只写在 `tests/go.mod` 里，`server/go.mod`、`client/v3/go.mod`、`api/go.mod` 里一个都没有[^etcd-tests]。`tests/go.mod` 用 `replace` 指向同仓库的 `../server`、`../client/v3` 等目录，测试的永远是同一次提交的代码。

单独成模块还有一个副作用：`./...` 不会进入含 `go.mod` 的子目录[^go-packages]，所以在 etcd 根目录 `go test ./...` 根本碰不到 `tests/`，这本身就是一道门。代价是每个模块要分别跑，etcd 的 `scripts/test.sh` 里 `unit_pass` 用 `run_for_all_workspace_modules` 带 `-short` 遍历所有模块，集成和 e2e 再各自进 `tests/` 跑[^etcd-test-sh]。

这个办法在小项目里不必要，testify 这样的小库进 `go.mod` 无伤大雅。它针对的是“会被很多人 import 的库，测试却要拖一堆重型依赖”的情况。

集成测试的问题解决了。etcd 的 `tests/` 里除了集成测试，还有 e2e 和 robustness。**集成测试和端到端测试到底差在哪？**

## 六、端到端测试与被测对象的距离

### 同一份测试，两种跑法

etcd 的 `tests/common` 给了一个很好的对照：同一个 `TestKVPut`，按构建标签选不同的“集群实现”[^etcd-common]。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 368" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gtl7-title gtl7-desc">
<title id="gtl7-title">etcd tests/common：一份测试，标签决定跑在哪</title>
<desc id="gtl7-desc">上方是 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 秒。</desc>
<defs>
<marker id="gtl7-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="368" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="336" rx="8"/>
<text class="t-zone" x="32" y="44">tests/common · go.etcd.io/etcd/tests/v3</text>
<path class="dd-edge-acc" d="M400 164 V180 M144 180 H656 M144 180 V196 M400 180 V196 M656 180 V196"/>
<rect class="dd-card" x="168" y="60" width="464" height="104" rx="6"/>
<path class="dd-card-head" d="M174 60 H626 Q632 60 632 66 V92 H168 V66 Q168 60 174 60 Z"/>
<text class="t-name" x="184" y="81">kv_test.go · TestKVPut</text>
<text class="t-mono" x="184" y="114">clus := <tspan class="t-acc">testRunner</tspan>.NewCluster(ctx, t, cfg)</text>
<text class="t-mono" x="184" y="136">cc.Put(ctx, "foo", "bar")</text>
<text class="t-mono" x="184" y="156">cc.Get(ctx, "foo")</text>
<rect class="dd-card" x="32" y="196" width="224" height="140" rx="6"/>
<path class="dd-card-head" d="M38 196 H250 Q256 196 256 202 V228 H32 V202 Q32 196 38 196 Z"/>
<text class="t-name" x="48" y="217">不带标签</text>
<text class="t-mono" x="48" y="250">unit_test.go</text>
<text class="t-val" x="48" y="274">UnitTestRunner</text>
<text class="t-note" x="48" y="300">“No test mode selected”</text>
<text class="t-note" x="48" y="320">直接退出</text>
<rect class="dd-card" x="288" y="196" width="224" height="140" rx="6"/>
<path class="dd-card-head" d="M294 196 H506 Q512 196 512 202 V228 H288 V202 Q288 196 294 196 Z"/>
<text class="t-name" x="304" y="217">-tags integration</text>
<text class="t-mono" x="304" y="250">integration_test.go</text>
<text class="t-val" x="304" y="274">IntegrationTestRunner</text>
<text class="t-note" x="304" y="300">集群成员在测试进程里</text>
<text class="t-mono" x="304" y="320"><tspan class="t-acc">0.72 s</tspan></text>
<rect class="dd-card" x="544" y="196" width="224" height="140" rx="6"/>
<path class="dd-card-head" d="M550 196 H762 Q768 196 768 202 V228 H544 V202 Q544 196 550 196 Z"/>
<text class="t-name" x="560" y="217">-tags e2e</text>
<text class="t-mono" x="560" y="250">e2e_test.go</text>
<text class="t-val" x="560" y="274">E2eTestRunner</text>
<text class="t-note" x="560" y="300">拉起 bin/etcd 子进程</text>
<text class="t-mono" x="560" y="320"><tspan class="t-acc">5.51 s</tspan></text>
</svg>
</div>
<figcaption>图 7 · 测试代码只依赖 testRunner 这个接口，标签决定集群是测试进程里的对象还是真的 etcd 进程</figcaption>
</figure>

实测（三个子用例：无 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` 这些参数对它不起作用[^etcd-test-sh]。

所以集成测试和 e2e 测试的区别，可以理解成**被测对象离测试进程有多远**。

### 从进程内到真实集群

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 412" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gtl8-title gtl8-desc">
<title id="gtl8-title">被测对象离测试进程越来越远的四级</title>
<desc id="gtl8-desc">四行。第一级，被测对象是测试进程里的对象：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 个测试文件在云上集群跑。</desc>
<defs>
<marker id="gtl8-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="412" rx="10"/>
<path class="dd-edge-acc" d="M40 60 V384" marker-end="url(#gtl8-arrow-acc)"/>
<text class="t-note" x="28" y="404">更远</text>
<rect class="dd-zone" x="64" y="16" width="720" height="84" rx="8"/>
<rect class="dd-zone" x="64" y="112" width="720" height="84" rx="8"/>
<rect class="dd-zone" x="64" y="208" width="720" height="84" rx="8"/>
<rect class="dd-zone" x="64" y="304" width="720" height="92" rx="8"/>
<text class="t-zone" x="80" y="44">① 进程内对象</text>
<text class="t-note" x="80" y="66">go test 直接跑</text>
<text class="t-zone" x="80" y="140">② 加真实依赖进程</text>
<text class="t-note" x="80" y="162">本机装好依赖</text>
<text class="t-zone" x="80" y="236">③ 拉起被测程序</text>
<text class="t-note" x="80" y="258">先编出二进制</text>
<text class="t-zone" x="80" y="332">④ 外部真实集群</text>
<text class="t-note" x="80" y="354">单独的测试程序</text>
<rect class="dd-card" x="256" y="28" width="248" height="60" rx="6"/>
<text class="t-mono" x="272" y="52">httptest.NewServer</text>
<text class="t-note" x="272" y="74">net/http 自带的测试服务器</text>
<rect class="dd-card" x="520" y="28" width="248" height="60" rx="6"/>
<text class="t-mono" x="536" y="52">etcd integration · testcluster</text>
<text class="t-note" x="536" y="74">etcd、CockroachDB 的进程内集群</text>
<rect class="dd-card" x="256" y="124" width="512" height="60" rx="6"/>
<text class="t-mono" x="272" y="148">framework.EtcdMain(m.Run)</text>
<text class="t-note" x="272" y="170">Kubernetes test/integration：起一个真 etcd 进程，apiserver 在测试进程里</text>
<rect class="dd-card" x="256" y="220" width="248" height="60" rx="6"/>
<text class="t-mono" x="272" y="244">bin/etcd × 3</text>
<text class="t-note" x="272" y="266">etcd e2e：事先 make build</text>
<rect class="dd-card" x="520" y="220" width="248" height="60" rx="6"/>
<text class="t-mono" x="536" y="244">os.Args[0] -test.main</text>
<text class="t-note" x="536" y="266">Prometheus：测试二进制当服务端</text>
<rect class="dd-card" x="256" y="316" width="248" height="72" rx="6"/>
<text class="t-mono" x="272" y="340">e2e.test</text>
<text class="t-note" x="272" y="360">Kubernetes：513 个普通 .go</text>
<text class="t-note" x="272" y="378">ginkgo，对已有集群跑</text>
<rect class="dd-card" x="520" y="316" width="248" height="72" rx="6"/>
<text class="t-mono" x="536" y="340">roachtest</text>
<text class="t-note" x="536" y="360">CockroachDB：237 个测试文件</text>
<text class="t-note" x="536" y="378">在云上建集群跑</text>
</svg>
</div>
<figcaption>图 8 · 从上到下，被测对象离测试进程越来越远：越往下越接近用户真实看到的系统，也越慢、越难调试</figcaption>
</figure>

图 8 按这个距离排了四级，每级举了真实项目的例子。

**第一、二级通常叫集成测试。** Kubernetes 的 138 个集成测试目录里，有 107 个的 `main_test.go` 只有一行 `framework.EtcdMain(m.Run)`。它先 `exec.LookPath("etcd")` 找到本机的 etcd 起一个真进程，再在测试进程里启动 apiserver 和控制器，测完用 goleak 检查有没有遗留的 goroutine[^k8s-etcdmain]。etcd 不好塞进测试进程（它是另一个项目），apiserver 是自己的代码，放进来才方便调试，这就是它停在第二级的原因。

**第三级开始是 e2e。** Prometheus 有一个很巧的办法：`cmd/prometheus/main_test.go` 的 `TestMain` 检查命令行，看到 `-test.main` 就直接调用 `main()`；测试用 `exec.Command(os.Args[0], "-test.main", "--config.file=...")` 把**测试二进制自己**当成 Prometheus 服务端拉起来[^prom-main-test]。这样不需要事先编译，被测程序和测试永远是同一份代码，而且这些测试在 `-short` 时跳过。

**第四级的测试本身是一个独立程序。** Kubernetes 的 `test/e2e` 下有 513 个普通 `.go` 文件，只有 33 个 `_test.go`。测试用例写在普通包里，再由 `e2e_test.go` 用一长串空导入拼起来[^k8s-e2e]：

```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` 程序，在云上开真实集群跑 nightly[^crdb-roachtest]。

为什么 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` 的目录被完全忽略[^go-test]，`./...` 也不会进入它[^go-packages]。在 `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，相当于有人“优化”了校验算法。

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="gtl-t3-title">checksum 的乘数从 7 改成 11 之后</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="gtl-t3-title">
<colgroup><col style="width:30%"><col style="width:16%"><col></colgroup>
<thead>
<tr><th scope="col">测试</th><th scope="col">结果</th><th scope="col">为什么</th></tr>
</thead>
<tbody>
<tr><th scope="row"><code>TestRoundTrip</code></th><td class="good">PASS</td><td>编码和校验用的是同一个新算法，仍然自洽</td></tr>
<tr><th scope="row"><code>TestChecksumStable</code></th><td class="good">PASS</td><td>只测了单字符 <code>"3"</code>，乘数不起作用</td></tr>
<tr><th scope="row"><code>TestEncodeGolden</code></th><td class="bad">FAIL</td><td class="mono">31 → 23b 变成 23f；961 → 223w 变成 223c</td></tr>
<tr><th scope="row"><code>ExampleEncode</code></th><td class="bad">FAIL</td><td class="mono">want 53z，got 53h</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">两个“自洽”的测试都通过了，但已经发出去的短链接全部失效。只有把旧输出存下来的测试发现了这件事。</p>
</div>
<figcaption>图 9 · golden 文件和 Example 的 Output 注释记住的是“过去的输出”，所以能发现兼容性破坏</figcaption>
</figure>

Kubernetes 把这个思路用在了 API 上。`staging/src/k8s.io/api/testdata/HEAD/` 里有 579 个序列化好的 API 对象（json、yaml、protobuf 三种格式），每次发布 1.x.0 之后，把 `HEAD` 复制一份成 `v1.34.0`、`v1.35.0` 这样的目录。测试保证当前代码还能把每个旧版本的数据解码成相同的对象[^k8s-api-testdata]。

### 数据驱动：测试用例本身就是文件

再往前一步，连“调什么函数、输入是什么”也写进文件，Go 代码只负责读文件、执行、比对。Russ Cox 在 GopherCon Australia 2023 的演讲里专门讲过这种写法：把测试用例写成文本文件，比写成 Go 的表格更容易增加[^rsc-testing]。

Prometheus 的 PromQL 测试是一个完整的例子。`promql/promqltest/testdata/` 下 22 个 `.test` 文件，是一门小小的测试语言[^prom-promqltest]：

```
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))
}
```

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 312" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gtl10-title gtl10-desc">
<title id="gtl10-title">数据驱动测试的循环</title>
<desc id="gtl10-desc">左边是 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 用实际输出写回文件。</desc>
<defs>
<marker id="gtl10-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gtl10-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="312" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="280" rx="8"/>
<text class="t-zone" x="32" y="44">Prometheus · go test ./promql -run TestEvaluations</text>
<path class="dd-edge" d="M272 124 H304" marker-end="url(#gtl10-arrow)"/>
<path class="dd-edge" d="M496 124 H528" marker-end="url(#gtl10-arrow)"/>
<path class="dd-edge dd-dash" d="M656 192 V236 Q656 244 648 244 H152 Q144 244 144 236 V200" marker-end="url(#gtl10-arrow)"/>
<rect class="dd-card" x="32" y="64" width="240" height="128" rx="6"/>
<path class="dd-card-head" d="M38 64 H266 Q272 64 272 70 V96 H32 V70 Q32 64 38 64 Z"/>
<text class="t-name" x="48" y="85">aggregators.test</text>
<text class="t-small" x="48" y="118">16 eval instant at 50m</text>
<text class="t-small" x="72" y="136">SUM BY (group) (...)</text>
<text class="t-small" x="48" y="156">17  {group="canary"} <tspan class="t-acc">701</tspan></text>
<text class="t-small" x="48" y="176">18   {group="production"} 300</text>
<rect class="dd-card" x="304" y="64" width="192" height="128" rx="6"/>
<path class="dd-card-head" d="M310 64 H490 Q496 64 496 70 V96 H304 V70 Q304 64 310 64 Z"/>
<text class="t-name" x="320" y="85">promql_test.go</text>
<text class="t-small" x="320" y="118">RunBuiltinTests(t, eng)</text>
<text class="t-note" x="320" y="148">22 个文件</text>
<text class="t-note" x="320" y="168">→ 2433 条 eval</text>
<text class="t-note" x="320" y="184">→ 2434 个子测试</text>
<rect class="dd-card" x="528" y="64" width="240" height="128" rx="6"/>
<path class="dd-card-head" d="M534 64 H762 Q768 64 768 70 V96 H528 V70 Q528 64 534 64 Z"/>
<text class="t-name" x="544" y="85">FAIL</text>
<text class="t-small" x="544" y="118">.../aggregators.test/line_16/</text>
<text class="t-small" x="544" y="136">SUM_BY_(group)_(...)</text>
<text class="t-small" x="544" y="160">expected <tspan class="t-acc">701</tspan> for</text>
<text class="t-small" x="544" y="178">{group="canary"} but got 700</text>
<text class="t-say" x="168" y="272">虚线：-update / --rewrite 用实际输出写回文件（golden、CockroachDB 的 datadriven）</text>
</svg>
</div>
<figcaption>图 10 · 用例和期望值都在文件里，Go 代码只是一个解释器；失败信息直接指回文件的行号</figcaption>
</figure>

实测 `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` 用实际输出改写文件[^crdb-datadriven]。`pkg/` 下的 testdata 里有 5990 个文件，其中 4693 个是这种格式。SQL 逻辑测试更进一步：`pkg/sql/logictest/testdata/logic_test/` 下 610 个 SQL 文件，由生成器按 26 种集群配置（单节点、三节点多租户、五节点等）生成 `generated_test.go`，一共 5764 个测试函数[^crdb-logictest]。同一个 SQL 文件在 26 种拓扑上各跑一遍，这在手写的 Go 测试里很难做到。

各项目的测试数据格式汇总如下：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="gtl-t4-title">把测试写成文件的几种格式</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="gtl-t4-title">
<colgroup><col style="width:16%"><col style="width:30%"><col style="width:20%"><col></colgroup>
<thead>
<tr><th scope="col">项目</th><th scope="col">文件</th><th scope="col" class="num">规模</th><th scope="col">更新期望输出</th></tr>
</thead>
<tbody>
<tr><th scope="row">Go 编译器</th><td><code>test/*.go</code>，首行 <code>// run</code>、<code>// errorcheck</code> 等指令</td><td class="num mono">3473 个</td><td>手写；<code>go test cmd/internal/testdir</code> 驱动</td></tr>
<tr><th scope="row">go 命令</th><td><code>cmd/go/testdata/script/*.txt</code>（txtar 脚本）</td><td class="num mono">951 个</td><td>手写</td></tr>
<tr><th scope="row">Prometheus</th><td><code>promqltest/testdata/*.test</code></td><td class="num mono">22 个 / 2433 条</td><td>手写</td></tr>
<tr><th scope="row" rowspan="2">CockroachDB</th><td>datadriven 文件</td><td class="num mono">4693 个</td><td rowspan="2" class="acc"><code>--rewrite</code></td></tr>
<tr><td>logictest SQL 文件</td><td class="num mono">610 个 → 5764 个测试</td></tr>
<tr><th scope="row">Kubernetes</th><td><code>api/testdata/HEAD</code> 与各版本快照</td><td class="num mono">579 个 / 版本</td><td>每次发版复制 HEAD</td></tr>
<tr><th scope="row">TiDB</th><td><code>tests/integrationtest</code> 的 <code>t/*.test</code> 与 <code>r/*.result</code></td><td class="num mono">264 个</td><td class="acc"><code>run-tests.sh -r</code></td></tr>
<tr><th scope="row">Hugo</th><td><code>testscripts/*.txt</code>；hugolib 里内联的 txtar 站点</td><td class="num mono">80 个 + 418 处</td><td>手写</td></tr>
<tr><th scope="row">Gitea</th><td><code>models/fixtures/*.yml</code>，一张表一个文件</td><td class="num mono">78 张表</td><td>手写，测试前灌进数据库</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">txtar 是 Go 自己的一种纯文本归档格式：用 “-- 文件名 --” 分隔多个文件，一段测试脚本连同它要用的所有文件写在一个 .txt 里。</p>
</div>
<figcaption>图 11 · 被测对象越像“一门语言”或“一个命令”（SQL、PromQL、go 命令、hugo 命令），测试越适合写成文件</figcaption>
</figure>

Go 仓库自己是这种写法最早的使用者：根目录 `test/` 下的编译器测试程序靠首行指令声明“应该能运行”还是“应该在第几行报什么错”[^go-testdir]，`go` 命令的 951 个脚本测试把命令、期望输出和所需文件一起写进 txtar[^go-script]。Hugo 用的是从 cmd/go 抽出来的 testscript 库[^hugo-testscripts]，Gitea 的 fixtures 则是最朴素的一种：一张表一个 YAML 文件，测试开始前灌进数据库[^gitea-fixtures]。

这些文件大多放在被测包的 `testdata/` 里，因为它们就是这个包的测试的输入，跟着包走最自然。例外是 Go 编译器的 `test/`、Hugo 的 `testscripts/`、TiDB 的 `tests/integrationtest` 和 Gitea 的 `models/fixtures`：它们测的是整个程序或整个数据库，不属于某一个包，于是和集成测试一样离开了包。

测试数据解决了输入和期望输出。可被测代码往往还依赖数据库、外部 API、另一个服务，**这些依赖在测试里拿什么代替？**

## 八、依赖替身：真实实例、fake 与 mock

### 各项目用什么替身

Go 社区常说“不要用 mock 框架，写个 fake 就好”。实际统计一下各项目的测试文件：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="gtl-t5-title">测试替身的使用（文件数或类型数）</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="gtl-t5-title">
<colgroup><col style="width:22%"><col class="hl"><col><col><col></colgroup>
<thead>
<tr><th scope="col">仓库</th><th scope="col" class="num acc"><code>type Fake…</code></th><th scope="col" class="num">gomock</th><th scope="col" class="num">testify/mock</th><th scope="col" class="num">testcontainers</th></tr>
</thead>
<tbody>
<tr><th scope="row">golang/go</th><td class="num mono acc">28</td><td class="num mono">0</td><td class="num mono">0</td><td class="num mono">0</td></tr>
<tr><th scope="row">Kubernetes</th><td class="num mono acc">808</td><td class="num mono">0</td><td class="num mono">20</td><td class="num mono">0</td></tr>
<tr><th scope="row">etcd</th><td class="num mono acc">35</td><td class="num mono">0</td><td class="num mono">0</td><td class="num mono">0</td></tr>
<tr><th scope="row">Prometheus</th><td class="num mono acc">27</td><td class="num mono">0</td><td class="num mono">2</td><td class="num mono">0</td></tr>
<tr><th scope="row">CockroachDB</th><td class="num mono acc">78</td><td class="num mono">37</td><td class="num mono">31</td><td class="num mono">0</td></tr>
<tr><th scope="row">TiDB</th><td class="num mono acc">37</td><td class="num mono">85</td><td class="num mono">18</td><td class="num mono">0</td></tr>
<tr><th scope="row">Grafana</th><td class="num mono acc">448</td><td class="num mono">25</td><td class="num mono">362</td><td class="num mono">1</td></tr>
<tr><th scope="row">Consul</th><td class="num mono acc">36</td><td class="num mono">0</td><td class="num mono">180</td><td class="num mono">19</td></tr>
<tr><th scope="row">moby、containerd、Hugo、Gitea、MinIO</th><td class="num mono acc">0–31</td><td class="num mono" colspan="3">都是 0</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">gomock、testify/mock 按 import 它们的 .go 文件数统计；type Fake… 按类型声明数统计。</p>
</div>
<figcaption>图 12 · 多数项目靠手写或生成的 fake；mock 框架只在 Grafana、Consul、TiDB 这样的项目里大量出现，Go 仓库自己一个都不用</figcaption>
</figure>

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

**能起真的，就起真的。** 第六节那四级距离里，第一级其实就是“替身”的最佳形态：`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(...)`[^k8s-fake]。moby 的测试规范直接建议：“Consider accepting unexported interfaces instead of structs so that fakes can be provided for dependencies.”[^moby-testing]

**mock 框架是第三选择。** gomock、testify/mock 生成的对象记录“期望哪个方法被以什么参数调用几次”。它适合依赖非常多、又只关心调用协议的场景，Grafana 的服务层就是这样。代价是测试和实现的调用顺序绑在一起，重构时测试会大面积失败。

TiDB 还有一个少见的办法：failpoint。源码里写 `failpoint.Inject("name", func() {...})` 这样的标记，平时是空操作；`make ut` 先执行 `failpoint-enable`，把这些标记改写成可以从测试里触发的代码，跑完再还原[^tidb-failpoint]。它用来在真实代码路径上注入故障，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 了几乎所有包。它的解法是让工具包只持有一个“工厂”变量：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 352" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gtl11-title gtl11-desc">
<title id="gtl11-title">CockroachDB 用 TestMain 注入测试服务器工厂</title>
<desc id="gtl11-desc">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 就能起一个完整的测试服务器。</desc>
<defs>
<marker id="gtl11-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gtl11-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="352" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="320" rx="8"/>
<text class="t-zone" x="32" y="44">CockroachDB · 用 TestMain 注入，绕开循环依赖</text>
<path class="dd-edge" d="M264 102 H288" marker-end="url(#gtl11-arrow)"/>
<path class="dd-edge" d="M400 208 V148" marker-end="url(#gtl11-arrow)"/>
<path class="dd-edge-acc" d="M288 256 H160 Q152 256 152 248 V148" marker-end="url(#gtl11-arrow-acc)"/>
<path class="dd-edge-acc" d="M512 256 H640 Q648 256 648 248 V148" marker-end="url(#gtl11-arrow-acc)"/>
<text class="t-note" x="660" y="200">注入工厂</text>
<text class="t-note" x="72" y="200">取出工厂</text>
<rect class="dd-card" x="40" y="64" width="224" height="84" rx="6"/>
<path class="dd-card-head" d="M46 64 H258 Q264 64 264 70 V96 H40 V70 Q40 64 46 64 Z"/>
<text class="t-name" x="56" y="85">pkg/server</text>
<text class="t-mono" x="56" y="118"><tspan class="t-acc">TestServerFactory</tspan></text>
<text class="t-note" x="56" y="138">几乎 import 了所有包</text>
<rect class="dd-card" x="288" y="64" width="224" height="84" rx="6"/>
<path class="dd-card-head" d="M294 64 H506 Q512 64 512 70 V96 H288 V70 Q288 64 294 64 Z"/>
<text class="t-name" x="304" y="85">pkg/kv/kvserver</text>
<text class="t-mono" x="304" y="118">replica.go …</text>
<text class="t-note" x="304" y="138">被 server import</text>
<rect class="dd-card" x="536" y="64" width="224" height="84" rx="6"/>
<path class="dd-card-head" d="M542 64 H754 Q760 64 760 70 V96 H536 V70 Q536 64 542 64 Z"/>
<text class="t-name" x="552" y="85">testutils/serverutils</text>
<text class="t-mono" x="552" y="118">StartServer(t, args)</text>
<text class="t-note" x="552" y="138">不 import server</text>
<rect class="dd-card" x="288" y="208" width="224" height="96" rx="6"/>
<path class="dd-card-head" d="M294 208 H506 Q512 208 512 214 V240 H288 V214 Q288 208 294 208 Z"/>
<text class="t-name" x="304" y="229">kvserver_test · main_test.go</text>
<text class="t-mono" x="304" y="262">InitTestServerFactory(</text>
<text class="t-mono" x="304" y="282">  server.<tspan class="t-acc">TestServerFactory</tspan>)</text>
</svg>
</div>
<figcaption>图 13 · 工具包只存一个工厂变量，真正的实现由外部测试包的 TestMain 塞进去，依赖关系里没有环</figcaption>
</figure>

`test_server_shim.go` 的注释说得很清楚：这些接口让测试“set up test servers without importing the server package (avoiding circular dependencies)”[^crdb-shim]。CockroachDB 的 311 个 `TestMain` 里有 284 个做的就是这件事，再加上 `randutil.SeedForTests()` 固定随机种子。这是第三节外部测试包思路的放大版：真正“看得见一切”的代码只出现在测试二进制的最外层。

## 九、回到开头：一个项目可以怎么摆

回到开头的 `net/http`。现在再看那个目录：`_test.go` 和代码交错，是因为 Go 的包就是目录，单元测试必须在里面；`export_test.go` 是给外部测试包开的门；`testdata/` 是 go 工具约定的数据目录。它看起来乱，但每个文件的位置都由规则决定，不需要额外的约定。

把各项目的做法合在一起，一个中等规模的 Go 项目可以这样摆：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gtl12-title gtl12-desc">
<title id="gtl12-title">一个中等规模项目的测试布局</title>
<desc id="gtl12-desc">左边是目录树：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 目录跑。</desc>
<rect class="dd-frame" width="800" height="420" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="488" height="388" rx="8"/>
<rect class="dd-zone" x="520" y="16" width="264" height="388" rx="8"/>
<text class="t-zone" x="32" y="44">目录</text>
<text class="t-zone" x="536" y="44">什么时候跑</text>
<text class="t-mono" x="40" y="76">shortlink/</text>
<text class="t-mono" xml:space="preserve" x="40" y="100">├─ go.mod</text>
<text class="t-mono" xml:space="preserve" x="40" y="124">├─ slug/</text>
<text class="t-mono" xml:space="preserve" x="40" y="148">│  ├─ slug.go</text>
<text class="t-mono" xml:space="preserve" x="40" y="172">│  ├─ slug_test.go</text>
<text class="t-note" x="248" y="172">package slug，测 checksum</text>
<text class="t-mono" xml:space="preserve" x="40" y="196">│  ├─ example_test.go</text>
<text class="t-note" x="248" y="196">package slug_test</text>
<text class="t-mono" xml:space="preserve" x="40" y="220">│  ├─ export_test.go</text>
<text class="t-note" x="248" y="220">需要时才加</text>
<text class="t-mono" xml:space="preserve" x="40" y="244">│  └─ testdata/encode.golden</text>
<text class="t-mono" xml:space="preserve" x="40" y="268">├─ store/</text>
<text class="t-mono" xml:space="preserve" x="40" y="292">│  ├─ store.go</text>
<text class="t-mono" xml:space="preserve" x="40" y="316">│  └─ store_test.go</text>
<text class="t-note" x="248" y="316">集成测试，<tspan class="t-acc">-short</tspan> 时跳过</text>
<text class="t-mono" xml:space="preserve" x="40" y="340">├─ internal/testutil/</text>
<text class="t-note" x="248" y="340">共享的测试工具，普通 .go</text>
<text class="t-mono" xml:space="preserve" x="40" y="364">├─ cmd/server/main.go</text>
<text class="t-mono" xml:space="preserve" x="40" y="388">└─ e2e/</text>
<text class="t-note" x="248" y="388">拉起 server；依赖重就单独成模块</text>
<rect class="dd-card" x="536" y="64" width="232" height="92" rx="6"/>
<path class="dd-card-head" d="M542 64 H762 Q768 64 768 70 V96 H536 V70 Q536 64 542 64 Z"/>
<text class="t-name" x="552" y="85">每次保存</text>
<text class="t-mono" x="552" y="120">go test <tspan class="t-acc">-short</tspan> ./...</text>
<text class="t-note" x="552" y="142">单元测试，秒级</text>
<rect class="dd-card" x="536" y="172" width="232" height="92" rx="6"/>
<path class="dd-card-head" d="M542 172 H762 Q768 172 768 178 V204 H536 V178 Q536 172 542 172 Z"/>
<text class="t-name" x="552" y="193">提交前 · CI</text>
<text class="t-mono" x="552" y="228">go test -race ./...</text>
<text class="t-note" x="552" y="250">加上集成测试</text>
<rect class="dd-card" x="536" y="280" width="232" height="108" rx="6"/>
<path class="dd-card-head" d="M542 280 H762 Q768 280 768 286 V312 H536 V286 Q536 280 542 280 Z"/>
<text class="t-name" x="552" y="301">合并前 · nightly</text>
<text class="t-mono" x="552" y="336">go build ./cmd/server</text>
<text class="t-mono" x="552" y="356">cd e2e &amp;&amp; go test ./...</text>
<text class="t-note" x="552" y="378">对编好的二进制跑</text>
</svg>
</div>
<figcaption>图 14 · 单元测试和测试数据贴着包；慢的测试用 -short 门控而不是构建标签；只有真正拉起程序的 e2e 才离开包</figcaption>
</figure>

几条取舍的理由，都来自前面的实验：

- 集成测试优先用 `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 注入工厂解决。

## 参考资料
