被测试的代码
Go 的测试工具是标准工具链的一部分:testing 包和 go test 命令。不需要安装框架,也不需要断言库。
本页的示例都在测试一个小函数 Slugify,它把标题转换成 URL slug。下面是可以运行的版本,在 main 里手写了检查:
浏览器中的编辑器运行的是 main,无法运行 go test。下面的内容都按照真实项目中的样子来写,每条命令之后都附上终端输出。所有内容都在 Go 1.24 下运行过。
第一个测试
在项目中,函数位于一个包里,测试就放在它旁边一个以 _test.go 结尾的文件中:
textutil/
├── go.mod module example.com/textutil
├── slug.go package textutil, func Slugify
└── slug_test.go package textutil, the tests
// slug_test.go
package textutil
import "testing"
func TestSlugify(t *testing.T) {
got := Slugify("Hello, World!")
want := "hello-world"
if got != want {
t.Errorf("Slugify(%q) = %q, want %q", "Hello, World!", got, want)
}
}
go test 遵循的规则:
- 只有以
_test.go结尾的文件才是测试文件。go build会忽略它们,所以测试代码永远不会进入你的二进制文件。 - 测试是一个名为
TestXxx的函数(Test之后的部分不能以小写字母开头),接收一个*testing.T参数。 - 只要测试没有调用任何失败方法,也没有 panic,它就通过。
go test # the package in the current directory
go test ./... # every package in the module
$ go test
PASS
ok example.com/textutil 0.318s
失败信息的格式 Func(input) = got, want expected 是 Go 的惯例。把输入写进信息里:如果失败信息只有 got "a", want "b",你还得回到代码里去查到底是哪个输入。
t.Errorf 与 t.Fatalf
| 方法 | 标记为失败 | 停止测试 |
|---|---|---|
t.Error、t.Errorf | 是 | 否,继续执行 |
t.Fatal、t.Fatalf | 是 | 是,立即停止 |
t.Log、t.Logf | 否 | 否,只在加 -v 或失败时打印 |
t.Skip、t.Skipf | 否 | 是,报告为已跳过 |
默认用 Errorf,这样一次运行就能报告所有出错的字段。当测试的其余部分无法继续时用 Fatalf,通常是在出现意外错误之后:
func TestCountWords(t *testing.T) {
path := writeFile(t, "doc.txt", "the quick brown\nfox jumps\n")
got, err := CountWords(path)
if err != nil {
t.Fatalf("CountWords: unexpected error: %v", err) // stop: got is meaningless
}
if got != 5 {
t.Errorf("CountWords = %d, want 5", got)
}
}
Fatal 通过调用 runtime.Goexit 停止测试,所以必须在测试自己的 goroutine 中调用。在你另外启动的 goroutine 里,请用 t.Error 报告,然后返回。
用 t.Run 写表驱动测试
大多数 Go 测试都是表格形式:一个用例切片、一个循环,再用 t.Run 给每个用例单独命名。
func TestSlugifyTable(t *testing.T) {
tests := []struct {
name, in, want string
}{
{"simple", "Go Testing", "go-testing"},
{"punctuation", "What's new in Go 1.24?", "what-s-new-in-go-1-24"},
{"extra spaces", " lots of space ", "lots-of-space"},
{"non-ascii dropped", "Café au lait", "caf-au-lait"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Slugify(tt.in); got != tt.want {
t.Errorf("Slugify(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
添加一个用例只需一行。每个子测试单独报告,一个子测试中的 Fatal 只结束这个子测试,而且你可以按名字单独运行某一个。用 -v 运行全部测试:
$ go test -v
=== RUN TestSlugify
--- PASS: TestSlugify (0.00s)
=== RUN TestSlugifyTable
=== RUN TestSlugifyTable/simple
=== RUN TestSlugifyTable/punctuation
=== RUN TestSlugifyTable/extra_spaces
=== RUN TestSlugifyTable/non-ascii_dropped
=== RUN TestSlugifyTable/empty
--- PASS: TestSlugifyTable (0.00s)
--- PASS: TestSlugifyTable/simple (0.00s)
--- PASS: TestSlugifyTable/punctuation (0.00s)
--- PASS: TestSlugifyTable/extra_spaces (0.00s)
--- PASS: TestSlugifyTable/non-ascii_dropped (0.00s)
--- PASS: TestSlugifyTable/empty (0.00s)
=== RUN TestCountWords
--- PASS: TestCountWords (0.00s)
=== RUN TestCountWordsMissingFile
--- PASS: TestCountWordsMissingFile (0.00s)
=== RUN ExampleSlugify
--- PASS: ExampleSlugify (0.00s)
PASS
ok example.com/textutil 0.182s
子测试名中的空格会变成下划线。当某个用例失败时,输出会指明子测试和行号:
--- FAIL: TestSlugifyTable (0.00s)
--- FAIL: TestSlugifyTable/underscore (0.00s)
slug_test.go:27: Slugify("snake_case_name") = "snake-case-name", want "snake_case_name"
FAIL
FAIL example.com/textutil 0.289s
从 Go 1.22 起,每次循环迭代都有自己的 tt,所以即使子测试并行运行,t.Run 中的闭包也能拿到正确的用例。旧代码常常因此在循环体开头写 tt := tt;现在已经不需要了。
要让子测试并行运行,就在子测试函数开头调用 t.Parallel()。只有当用例彼此独立、而且慢到值得并行时才这样做。
常用的 go test 参数
| 命令 | 作用 |
|---|---|
go test -v | 打印每个测试的名字、结果和 t.Log 输出 |
go test -run TestSlugify | 运行名字匹配该正则表达式的测试 |
go test -run 'TestSlugifyTable/empty' | 运行一个子测试 |
go test -count=1 | 忽略缓存的结果,重新运行 |
go test -race | 开启数据竞争检测器运行 |
go test -short | 让耗时长的测试自行跳过(if testing.Short() { t.Skip() }) |
go test -failfast | 第一个测试失败后就停止 |
go test -timeout 30s | 运行时间超过限制就判为失败(默认 10 分钟) |
go test -cover | 打印语句覆盖率 |
go test -bench=. | 同时运行基准测试 |
当你指定包名时(go test .、go test ./...),对于代码和输入都没有变化的包,go test 会缓存结果,并在后面打印 (cached)。不带参数的 go test 从不使用缓存。缓存会跟踪测试通过 os 包读取的文件和环境变量,但不会跟踪数据库或网络服务这样的外部状态,所以依赖外部状态的测试可能从缓存中显示通过,而实际现在已经会失败;用 -count=1 可以强制真正运行。
辅助函数、临时目录与清理
上面 TestCountWords 中调用的 writeFile 就是一个测试辅助函数:
// writeFile is a test helper: t.Helper makes failures point at the caller.
func writeFile(t *testing.T, name, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name) // removed automatically after the test
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("writing %s: %v", name, err)
}
return path
}
t.Helper()把函数标记为辅助函数,这样失败会报告在测试中调用它的那一行,而不是辅助函数内部。t.TempDir()创建一个新目录,并在测试结束时删除它。测试永远不需要自己清理文件。t.Cleanup(func() { ... })注册其他收尾工作(关闭服务器、删除表)。清理函数在测试及其子测试结束后运行,后注册的先执行。t.Setenv("KEY", "value")只为当前测试设置一个环境变量,结束后自动恢复。t.Context()(Go 1.24)返回一个 context,它会在清理函数运行之前被取消。
测试用的数据文件放在测试旁边一个名为 testdata 的目录里。go 工具不会把它当作包,而且测试运行时以包目录作为工作目录,所以 os.ReadFile("testdata/input.json") 可以正常工作。
测试错误和 HTTP 处理函数
用 errors.Is 或 errors.As 检查错误,和调用方代码的做法一样:
func TestCountWordsMissingFile(t *testing.T) {
_, err := CountWords(filepath.Join(t.TempDir(), "nope.txt"))
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("err = %v, want fs.ErrNotExist", err)
}
}
一旦有人改了错误信息的措辞,比较错误字符串的测试就会失败。
对于 HTTP 处理函数,net/http/httptest 提供了一个假的 ResponseWriter,这样你可以直接调用处理函数,不需要网络:
func TestHealth(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
healthHandler(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
if body := rec.Body.String(); body != "ok\n" {
t.Errorf("body = %q, want %q", body, "ok\n")
}
}
httptest.NewServer 会在 localhost 上启动一个真实的服务器,用来测试客户端代码。HTTP 客户端一节的每个示例都用到了它。
覆盖率
$ go test -cover
PASS
coverage: 100.0% of statements
ok example.com/textutil 0.578s
$ go test -coverprofile=cover.out
$ go tool cover -func=cover.out
example.com/textutil/slug.go:11: Slugify 100.0%
example.com/textutil/words.go:9: CountWords 100.0%
total: (statements) 100.0%
$ go tool cover -html=cover.out # opens a browser with covered lines in green
覆盖率告诉你哪些行从未运行过,这对找出没有测试到的分支很有用。但数字高并不说明断言写得好:一个调用了所有函数却什么都不检查的测试,也能达到 100%。
基准测试
基准测试是 func BenchmarkXxx(b *testing.B)。Go 1.24 加入了 b.Loop,现在推荐使用这种写法:
func BenchmarkSlugify(b *testing.B) {
for b.Loop() {
Slugify("The Go Programming Language, 2nd Edition")
}
}
b.Loop 会把循环体运行足够多次以得到稳定的测量结果,把循环之前的准备代码排除在计时之外,还能防止编译器把调用优化掉。Go 1.24 之前的代码用 for i := 0; i < b.N; i++,它仍然可用,但在耗时的准备工作之后需要调用 b.ResetTimer(),而且可能被死代码消除干扰。
基准测试不会在普通的 go test 中运行。需要显式要求,并用 -run='^$' 跳过普通测试:
$ go test -bench=. -benchmem -run='^$'
goos: darwin
goarch: arm64
pkg: example.com/textutil
cpu: Apple M4
BenchmarkSlugify-10 5061945 238.1 ns/op 168 B/op 5 allocs/op
PASS
ok example.com/textutil 1.410s
各列依次是:名字(后面附加 GOMAXPROCS)、运行的迭代次数、每次调用的耗时、每次调用分配的字节数、每次调用的分配次数。这些数字取决于机器;请在同一台机器上比较。要可靠地比较修改前后的差异,用 -count=10 把两边各运行多次,再把两份输出交给 benchstat(golang.org/x/perf/cmd/benchstat)。
示例也是测试
示例函数会打印一些内容,并在注释中声明预期输出。go test 会运行它,输出不一致就判为失败;go doc 和 pkg.go.dev 还会把它作为文档展示:
// example_test.go
package textutil_test
import (
"fmt"
"example.com/textutil"
)
func ExampleSlugify() {
fmt.Println(textutil.Slugify("Hello, World!"))
// Output: hello-world
}
包名 textutil_test 使它成为一个外部测试:它只能使用导出的 API,就像真正的调用方一样。这种文件可以和包放在同一个目录下。没有 // Output: 注释的示例只会编译,不会运行。当输出的各行顺序不固定时,用 // Unordered output:。
模糊测试简介
Go 1.18 加入了模糊测试,它会自动生成输入,用来发现崩溃和被破坏的不变式:
func FuzzSlugify(f *testing.F) {
f.Add("Hello, World!") // seed input
f.Fuzz(func(t *testing.T, s string) {
slug := Slugify(s)
if strings.Contains(slug, "--") || strings.HasPrefix(slug, "-") {
t.Errorf("Slugify(%q) = %q: bad hyphens", s, slug)
}
})
}
普通的 go test 只运行种子输入(f.Add 添加的值,以及 testdata/fuzz 下保存的文件)。go test -fuzz=FuzzSlugify 会不断生成新的输入,直到发现失败或者你手动停止,并把导致失败的输入保存到 testdata/fuzz 下,让它们成为普通的测试用例。
常见错误
- 测试文件位置不对或命名错误。 它必须以
_test.go结尾,并放在包目录中。slug_tests.go会被编译进包里,而不是作为测试运行。 Test后面跟小写字母。Testslugify不是测试,TestSlugify和Test_slugify才是。- 在另一个 goroutine 中调用
t.Fatal。 在那里请用t.Error报告,并在测试返回之前等待该 goroutine 结束。 - 失败信息里没有输入。 写清楚传入了什么、得到了什么、期望的是什么。
- 相信缓存的通过结果。 当测试依赖包以外的任何东西时,请用
-count=1。 - 测试依赖彼此的执行顺序或共享的全局变量。 每个测试都应该自己准备状态,
t.TempDir、t.Setenv和t.Cleanup正是为此而设计的。
常见问题
Go 中如何写单元测试?
在代码所在的目录下创建一个以 _test.go 结尾的文件,导入 testing,写一个 func TestName(t *testing.T) 函数,在里面调用你的代码,并用 t.Errorf 报告不一致的结果。在该目录下运行 go test,或者运行 go test ./... 测试整个模块。不需要任何框架或断言库。
Go 中 t.Error 和 t.Fatal 有什么区别?
t.Error 和 t.Errorf 把测试标记为失败并继续执行,所以一次运行可以报告多个问题。t.Fatal 和 t.Fatalf 把测试标记为失败并立即停止。当继续执行已经没有意义时用 Fatal,例如出现意外错误、结果已经无法使用的时候。
Go 中如何只运行一个测试?
给 -run 传一个正则表达式:go test -run TestSlugify 运行所有名字匹配的测试,go test -run 'TestSlugifyTable/punctuation' 运行一个子测试(子测试名中的空格会变成下划线)。加上 -v 可以看到每个测试的名字和结果,加上 -count=1 可以绕过测试缓存。
Go 中如何写基准测试?
在 _test.go 文件中写一个 func BenchmarkName(b *testing.B),把要测量的代码放进 for b.Loop() { ... } 循环(Go 1.24;旧代码用 for i := 0; i < b.N; i++)。用 go test -bench=. -benchmem 运行,它会报告每次操作的纳秒数、字节数和内存分配次数。
Go 中如何查看测试覆盖率?
go test -cover 打印测试执行到的语句百分比。要看详细信息,先用 go test -coverprofile=cover.out 生成概要文件,再运行 go tool cover -func=cover.out 查看每个函数的数据,或者运行 go tool cover -html=cover.out 在浏览器中查看哪些行被覆盖、哪些没有。