Menu

Golang 测试:单元测试、表驱动测试与基准测试

如何用标准库 testing 包和 go test 测试 Go 代码:_test.go 文件、TestXxx 函数、t.Errorf 与 t.Fatalf 的区别、用 t.Run 写表驱动测试、辅助函数与临时目录、覆盖率、用 b.Loop 写基准测试,以及示例测试。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

被测试的代码

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 在浏览器中查看哪些行被覆盖、哪些没有。

Coddy 编程语言插图

和 Coddy 一起学编程

开始