需要预先了解:


1. 测试

Go 把测试做成了语言工具链的一部分:_test.go 文件、go test 命令、testing 包,不需要额外框架。这一篇讲单元测试、表驱动测试、基准测试、模糊测试、示例测试和 HTTP 测试的写法,以及覆盖率怎么读。


1.1 测试文件的组织

测试代码放在以 _test.go 结尾的文件里,和被测试代码同一个目录:

calc/
├── calc.go          // package calc
├── calc_test.go     // package calc(同包测试)
└── calc_ext_test.go // package calc_test(外部测试包)

两种包名的区别:

写法 能访问 适合
package calc 包内所有标识符(含未导出的) 测试内部实现
package calc_test 只有导出的标识符 测试对外 API,避免依赖内部细节

同一个目录下两种测试文件可以共存。外部测试包需要 import 被测包:

package calc_test

import (
	"testing"

	"note26/calc"
)

func TestExternalPackage(t *testing.T) {
	if calc.Add(1, 2) != 3 {
		t.Error("Add 结果不对")
	}
}

1.2 单元测试

测试函数必须以 Test 开头,签名是 func TestXxx(t *testing.T):

func TestAdd(t *testing.T) {
	if got := Add(1, 2); got != 3 {
		t.Errorf("Add(1, 2) = %d, 期望 3", got)
	}
}

失败相关的几个方法:

方法 行为
t.Error 标记失败,继续执行当前测试
t.Errorf 带格式化,标记失败,继续执行
t.Fatal 标记失败并立即终止当前测试函数
t.Fatalf 带格式化,立即终止
t.FailNow 只终止,不额外记录信息

一般原则:断言失败用 Error,继续跑完所有检查(能看到更多问题);前置条件不成立用 Fatal(继续跑下去会 panic 或产生无意义的报错)。

t.Log 记录的信息默认不显示,加 -v 或测试失败时才输出:

=== RUN   TestWithLog
    calc_test.go:84: 这条日志只在 -v 或失败时显示
    calc_test.go:85: 带格式化: 42
--- PASS: TestWithLog (0.00s)

1.3 表驱动测试

同一段逻辑测多组输入时,把用例写成表:

func TestTableDriven(t *testing.T) {
	tests := []struct {
		name    string
		a, b    int
		want    int
		wantErr bool
	}{
		{"正整数", 10, 2, 5, false},
		{"整除余数", 7, 2, 3, false},
		{"负数", -9, 3, -3, false},
		{"除数为零", 1, 0, 0, true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := Div(tt.a, tt.b)
			if tt.wantErr {
				if err == nil {
					t.Fatalf("期望出错,但没有")
				}
				return
			}
			if err != nil {
				t.Fatalf("意外错误: %v", err)
			}
			if got != tt.want {
				t.Errorf("Div(%d, %d) = %d, 期望 %d", tt.a, tt.b, got, tt.want)
			}
		})
	}
}

t.Run 创建子测试,好处是:

  • 输出里能看到具体哪个用例失败;
  • 可以单独运行某个用例:go test -run 'TestTableDriven/除数为零';
  • 可以在子测试里调用 t.Parallel()。
=== RUN   TestTableDriven/正整数
=== RUN   TestTableDriven/整除余数
=== RUN   TestTableDriven/负数
=== RUN   TestTableDriven/除数为零
--- PASS: TestTableDriven (0.00s)
    --- PASS: TestTableDriven/正整数 (0.00s)
    ...

并行子测试

for _, tt := range tests {
	tt := tt
	t.Run("", func(t *testing.T) {
		t.Parallel()
		// ...
	})
}

t.Parallel() 会暂停当前子测试,让其他并行子测试先跑。注意循环变量的捕获:Go 1.22 之前需要 tt := tt,1.22+ 每轮迭代已经是新变量(见 并发设计模式与陷阱总结)。


1.4 基准测试

基准函数以 Benchmark 开头,签名是 func BenchmarkXxx(b *testing.B):

func BenchmarkAdd(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = Add(i, i+1)
	}
}

b.N 由测试框架自适应确定:先小规模试跑,再逐步增大,直到测量时间足够长(默认约 1 秒),最后报告"每次操作耗时"。

计时控制:

b.ResetTimer()     // 重置计时,忽略之前的准备工作
b.StopTimer()      // 暂停计时(做准备工作)
b.StartTimer()     // 恢复计时
b.ReportAllocs()   // 报告内存分配(等价于命令行加 -benchmem)

实测输出:

BenchmarkAdd-24              	1000000000	         0.1099 ns/op	       0 B/op	       0 allocs/op
BenchmarkFormat-24           	90657721	        12.95 ns/op	       7 B/op	       0 allocs/op
BenchmarkConcatPlus-24       	 1234840	      1173 ns/op	    1504 B/op	      49 allocs/op
BenchmarkConcatBuilder-24    	 5698204	       193.5 ns/op	     120 B/op	       4 allocs/op

列的含义:

列 含义
BenchmarkX-24 函数名;-24 是 GOMAXPROCS
ns/op 每次操作耗时(纳秒)
B/op 每次操作分配的内存字节数
allocs/op 每次操作分配次数

看性能问题先看 allocs/op:分配次数多通常意味着 GC 压力大。上面 + 拼接字符串每次 49 次分配、1504 字节,Builder 只有 4 次分配、120 字节——和 切片、map 与字符串的底层实现 里的结论一致。

运行方式:

go test -bench . -benchmem -run "^$"

-run "^$" 表示不跑普通测试,只跑基准。


1.5 模糊测试(Go 1.18+)

**模糊测试(fuzzing)**自动生成输入,寻找让程序崩溃或违反断言的用例:

func FuzzAdd(f *testing.F) {
	f.Add(1, 2)   // 种子语料
	f.Add(-1, 1)
	f.Add(0, 0)

	f.Fuzz(func(t *testing.T, a, b int) {
		got := Add(a, b)
		if got != b+a {
			t.Errorf("Add(%d, %d) 交换顺序后不一致", a, b)
		}
	})
}
  • f.Add 提供种子语料,go test 默认会跑这些种子;
  • go test -fuzz=FuzzAdd -fuzztime=3s 才会真正开始生成随机输入:
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 24 workers
fuzz: elapsed: 3s, execs: 2309425 (769667/sec), new interesting: 0 (total: 3)
PASS
  • 一旦发现失败用例,它会被写入 testdata/fuzz/FuzzXxx/ 目录,之后每次 go test 都会复现它;
  • 参数类型支持 string、[]byte、int、float64、bool 等基本类型。

模糊测试特别适合解析器、编解码器这类"输入空间巨大"的代码。


1.6 示例测试

ExampleXxx 函数既当文档又当测试,用 // Output: 注释声明期望输出:

func ExampleAdd() {
	fmt.Println(Add(1, 2))
	// Output: 3
}

func ExampleFormat() {
	fmt.Println(Format(42))
	fmt.Println(Format(-1))
	// Output:
	// 42
	// -1
}

go test 会执行它并比对输出,不一致就失败。它们会出现在 go doc 里,作为可执行的文档。


1.7 httptest

测试 HTTP 处理器有两种方式。

httptest.NewRecorder:不启动真实服务器,直接调用 ServeHTTP 并检查响应:

body := strings.NewReader(`{"name":"一撮灰"}`)
req := httptest.NewRequest(http.MethodPost, "/users", body)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)

if rec.Code != http.StatusCreated {
	t.Fatalf("创建状态码 = %d, 期望 201", rec.Code)
}

httptest.NewServer:启动一个真实的测试服务器,适合测试客户端逻辑或端到端流程:

srv := httptest.NewServer(h)
defer srv.Close()

resp, err := http.Post(srv.URL+"/users", "application/json", strings.NewReader(`{"name":"灰灰"}`))

httptest.NewServer 用的是真实网络端口,测试结束后要 Close。它也是 标准库(二):JSON 与 HTTP 服务 里那个服务的标准测试方式。


1.8 覆盖率

go test -cover ./...
ok  	note26/api	0.479s	coverage: 96.2% of statements
ok  	note26/calc	0.430s	coverage: 100.0% of statements

生成详细报告:

go test -coverprofile=cover.out ./calc
go tool cover -func=cover.out
go tool cover -html=cover.out   # 生成可视化 HTML
note26/calc/calc.go:11:	Add		100.0%
note26/calc/calc.go:16:	Div		100.0%
note26/calc/calc.go:24:	Format		100.0%
note26/calc/calc.go:29:	unexported	100.0%
total:			(statements)	100.0%

注意:覆盖率衡量的是"哪些代码被执行过",不是"哪些情况被验证过"。100% 覆盖的代码里照样可能藏着 bug。覆盖率适合用来发现完全没被测试碰到的代码,不适合当作质量指标。


1.9 testify 概览

标准库的断言写起来比较啰嗦,第三方库 github.com/stretchr/testify 提供了更简洁的写法:

包 作用
assert 断言失败记录错误并继续
require 断言失败立即终止当前测试
mock 生成 mock 对象
suite 测试套件,共享 setup/teardown
assert.Equal(t, 3, Add(1, 2))
require.NoError(t, err)

它不是必需的——标准库足够写完所有测试。团队引入前先确认收益是否大于多一个依赖。


1.10 go test 常用参数

参数 作用
-v 显示详细输出
-run 按正则筛选测试:-run 'TestDiv/除数为零'
-bench 运行基准测试
-benchmem 显示内存分配统计
-count 重复运行次数:-count=1 禁用缓存
-race 开启数据竞争检测
-timeout 超时时间,默认 10 分钟
-short 启用"短模式",测试内用 testing.Short() 判断
-cover 输出覆盖率
-fuzz 运行模糊测试

go test 默认会缓存成功的测试结果,改了代码或加 -count=1 才会重跑。


1.11 练习

  1. 给 泛型 里的 Set[T] 写表驱动测试,覆盖 Add、Has、Remove、重复添加等场景。
  2. 给 标准库(一):IO 与系统交互 里的文件读写函数写测试,用 t.TempDir() 创建临时目录,避免污染工作目录。
  3. 写一个基准测试对比 map 与 slices 线性查找在 10、100、1000 个元素时的性能差异,根据 ns/op 给出"多大时该用 map"的建议。