Go 测试基础概念

Go 的测试能力是语言原生内建的,不需要任何第三方框架。核心命令 go test 会自动发现并运行符合约定的测试代码。Go 提供四种测试类型,均通过函数名前缀区分:

  • 单元测试(Unit Tests):函数名以 Test 开头,签名 func TestXxx(t *testing.T)
  • 示例测试(Example Tests):函数名以 Example 开头,无参数,通过 // Output: 注释校验输出
  • 基准测试(Benchmarks):函数名以 Benchmark 开头,签名 func BenchmarkXxx(b *testing.B)
  • 模糊测试(Fuzz Tests):函数名以 Fuzz 开头,签名 func FuzzXxx(f *testing.F)(Go 1.18+)

文件与包约定

所有测试文件必须以 _test.go 结尾,编译时不会进入最终产物。测试文件放在与被测代码相同的目录下,但包名有两种写法,对应两种测试组织方式:

写法包名可访问性适用场景
内部包测试package foo(与被测包同名)可访问未导出成员测试内部实现细节
外部包测试package foo_test只能访问导出成员测试公开 API,避免循环导入
1
2
3
4
5
6
7
8
9
10
// internal_test.go —— 内部包测试,可访问私有成员
package foo

import "testing"

func TestPrivateHelper(t *testing.T) {
if result := privateHelper(); result != 42 {
t.Errorf("got %d, want 42", result)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// external_test.go —— 外部包测试,只测公开 API
package foo_test

import (
"testing"
"example.com/foo"
)

func TestPublicAPI(t *testing.T) {
if got := foo.PublicFunc(); got != "ok" {
t.Errorf("got %q, want %q", got, "ok")
}
}

社区推荐优先使用外部包测试foo_test),它强制以调用方视角验证 API,且能打破被测包内部产生的导入环。只有需要验证私有实现时才回退到内部包测试。

单元测试(Unit Tests)

编写规则

测试函数签名固定为 func TestXxx(t *testing.T),其中 Xxx 首字母必须大写,后续部分通常是被测对象的名字。

1
2
3
4
5
6
7
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %v, want %v", got, want)
}
}

常用方法

t *testing.T 提供的方法分几类:

失败控制

  • t.Error / t.Errorf:记录错误并标记失败,继续执行当前测试
  • t.Fatal / t.Fatalf:记录错误并立即停止当前测试(跳过后续代码)
  • t.Skip / t.Skipf:跳过当前测试并说明原因
  • t.FailNow():标记失败并立即停止(Fatal 的底层实现)

日志

  • t.Log / t.Logf:打印日志,仅在 -v 模式或测试失败时显示

测试组织

  • t.Run(name, func):创建子测试,支持嵌套
  • t.Parallel():声明当前测试可与其他并行测试并发执行
  • t.Cleanup(func):注册清理函数,测试结束后按 LIFO 顺序执行
  • t.Helper():标记辅助函数,错误定位时跳过该函数的调用栈

环境隔离

  • t.Setenv(key, val):设置环境变量,测试结束自动恢复原值
  • t.TempDir():返回一个测试结束自动删除的临时目录

下面逐个说明几个关键方法。

t.Helper()

当多个测试共用一个断言辅助函数时,t.Helper() 能让错误堆栈指向调用处而非辅助函数内部,定位更直观:

1
2
3
4
5
6
7
8
9
10
11
// assertEqual 是辅助函数,错误应归因于调用方
func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper() // 关键:让 t.Errorf 报告调用 assertEqual 的那一行
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}

func TestCalc(t *testing.T) {
assertEqual(t, Add(2, 3), 5) // 失败时指向这一行,而非 assertEqual 内部
}

t.Cleanup()、t.Setenv()、t.TempDir()

这三个方法替代了传统的 defer + 手动清理,更适配 Go 测试框架的并行与子测试模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func TestProcessFile(t *testing.T) {
// t.TempDir() 自动创建并在测试结束删除目录
dir := t.TempDir()
path := filepath.Join(dir, "input.txt")
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
t.Fatal(err)
}

// t.Setenv 修改环境变量,测试结束自动恢复
t.Setenv("CONFIG_PATH", path)

// t.Cleanup 注册的清理函数按 LIFO 顺序执行,
// 即使 t.Fatal 提前退出也会被调用
db, err := OpenDB("test.dsn")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })

// 正常测试逻辑...
}

注意:t.Setenv 会修改进程级环境变量,因此不能t.Parallel() 同时使用。

t.Skip()

1
2
3
4
5
6
func TestLongRunning(t *testing.T) {
if testing.Short() {
t.Skip("skipping long-running test in -short mode")
}
// 耗时操作...
}

go test -short 会在 CI 或本地快速迭代时跳过标记了长耗时的测试。

表驱动测试(推荐方式)

表驱动测试是 Go 社区最推崇的单元测试范式:把输入与期望输出组织成一张表,循环执行,每行作为一个子测试。好处是新增用例只改数据、不改逻辑,且每个用例独立报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"正数", 2, 3, 5},
{"负数", -1, 1, 0},
{"零", 0, 0, 0},
{"大数", 1 << 30, 1 << 30, 1 << 31},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Add(tt.a, tt.b); got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}

表驱动测试的并行版本

当用例之间互不影响时,可以让每个子测试并行运行以缩短总时间。这里有一个经典陷阱:循环变量捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func TestAddParallel(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"正数", 2, 3, 5},
{"负数", -1, 1, 0},
{"零", 0, 0, 0},
}

for _, tt := range tests {
tt := tt // Go 1.22 之前必须重新绑定,避免所有 goroutine 共用最后一个 tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // 声明子测试可并行
if got := Add(tt.a, tt.b); got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}

Go 1.22 修复了 for 循环变量每轮共享的问题,tt := tt 不再必需;但在团队混合版本时写上仍是无害的防御性写法。

TestMain:全局 setup/teardown

当所有测试需要共享初始化(如连接测试数据库、启动 mock 服务器)时,定义 TestMain

1
2
3
4
5
6
7
8
9
10
11
func TestMain(m *testing.M) {
// 所有测试运行前的全局初始化
db = setupTestDB()

code := m.Run() // 运行所有测试,返回退出码

// 所有测试运行后的全局清理
db.Close()

os.Exit(code) // 必须显式退出并透传退出码
}

TestMain 在每个测试包中最多定义一个,且必须调用 m.Run(),否则所有测试都不会执行。

运行单元测试

1
2
3
4
5
6
7
8
9
10
go test                         # 运行当前包所有测试
go test ./... # 递归运行所有子包测试
go test -v # 显示详细输出(包括 t.Log)
go test -run TestAdd # 只运行名字匹配 TestAdd 的测试(正则)
go test -run TestAdd/negative # 只运行子测试 'negative'(用 / 分层)
go test -count=1 # 禁用测试缓存(每次都重新运行)
go test -short # 跳过标记为长耗时的测试(配合 testing.Short())
go test -race # 开启竞态检测器(强烈建议 CI 中常开)
go test -parallel=8 # 限制可并行执行的测试数为 8
go test -timeout=30s # 单个测试超时 30s 后 panic(默认 10m)

-run 接收正则表达式,且对每个 / 分隔的层级独立匹配,例如 -run TestAdd/正 会匹配子测试名以"正"开头的用例。

示例测试(Example Tests)

作用

示例测试一举两得:

  1. 作为文档:会出现在 godoc 输出中,紧贴对应函数/类型
  2. 作为可执行测试:保证文档中的代码始终可编译、可运行、输出正确

这避免了"文档与代码脱节"的经典问题——示例代码一旦失效,测试就会失败。

编写方式

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

命名规则

示例函数的命名决定它在 godoc 中挂载到哪个位置:

函数名挂载位置
ExampleAdd函数 Add
ExampleAdd_order函数 Add,场景 _order(后缀仅用于区分多个示例)
ExampleUser类型 User
ExampleUser_Save类型 UserSave 方法
Example整个包

Output 匹配规则

  • // Output: 后内容必须与实际标准输出完全一致(含换行),但会忽略前导空白
  • // Unordered output: 不关心多行输出的顺序
  • 没有任何 // Output 注释的示例只编译、不运行(用于纯展示)
1
2
3
4
5
6
7
8
9
func ExampleSort() {
fmt.Println("c")
fmt.Println("a")
fmt.Println("b")
// Unordered output:
// a
// b
// c
}

运行:go test -run Example 或在 godoc 中直接查看。

基准测试(Benchmarks)

编写基准函数

基准测试的核心是让被测代码循环执行 b.N 次,框架会自动调整 b.N 的大小,使总运行时间达到设定的阈值:

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

b.N 的工作原理

b.N 并非固定值。框架的策略是:

  1. 先用较小的 b.N(如 1)试运行
  2. 测量耗时,估算要达到 -benchtime(默认 1 秒)需要多大 b.N
  3. 逐步放大 b.N 重新运行,直到实际运行时间满足阈值

因此同一个基准测试每次跑,b.N 可能不同——这是正常现象,目的是让测量误差足够小。

表驱动基准测试(推荐)

b.Run 创建子基准,便于对比不同输入规模下的性能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func BenchmarkAdd(b *testing.B) {
tests := []struct {
name string
a, b int
}{
{"small", 1, 2},
{"large", 1000000, 2000000},
}

for _, tt := range tests {
b.Run(tt.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(tt.a, tt.b)
}
})
}
}

常用运行命令

1
2
3
4
5
6
7
8
go test -bench=.                # 运行所有基准测试
go test -bench=BenchmarkAdd # 只运行特定基准(正则)
go test -bench=. -benchmem # 显示内存分配统计(B/op 和 allocs/op)
go test -bench=. -count=10 # 运行 10 次取平均(减少波动)
go test -bench=. -benchtime=5s # 每个基准至少运行 5 秒(更准确)
go test -bench=. -benchtime=100x # 每个基准固定运行 100 次(用 x 后缀)
go test -bench=. -cpu=1,4,8 # 测试不同 GOMAXPROCS 下的性能
go test -run=^$ -bench=. # 跳过单元测试,只跑基准(重要!)

-run=^$ 用一个匹配空字符串的正则,确保不运行任何单元测试,避免其耗时干扰基准测量。

输出解释

1
BenchmarkAdd-8          100000000       10.5 ns/op      0 B/op      0 allocs/op
含义
BenchmarkAdd-8名称 - 后的 8 表示运行时 GOMAXPROCS=8
100000000循环执行了 1 亿次(即 b.N
10.5 ns/op平均每次操作耗时 10.5 纳秒
0 B/op每次操作分配的字节数(需加 -benchmem
0 allocs/op每次操作的内存分配次数(需加 -benchmem

内存分配统计

-benchmem 是全局开关,也可以在单个基准内用 b.ReportAllocs() 开启:

1
2
3
4
5
6
7
8
9
10
func BenchmarkBuildString(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
s := ""
for j := 0; j < 100; j++ {
s += "x" // 每次拼接都可能触发分配
}
_ = s
}
}

衡量吞吐量时用 b.SetBytes(n),框架会额外输出 MB/s

1
2
3
4
5
6
7
8
9
func BenchmarkHash(b *testing.B) {
data := make([]byte, 4096)
b.SetBytes(int64(len(data))) // 告诉框架每次处理 4KB
b.ResetTimer()
for i := 0; i < b.N; i++ {
Sum(data)
}
}
// 输出会多一列:MB/s,直观反映吞吐

高级技巧

定时器控制——初始化耗时不应计入测量:

1
2
3
4
5
6
7
func BenchmarkFib(b *testing.B) {
// 准备数据、初始化等耗时操作放在 ResetTimer 之前
b.ResetTimer() // 排除初始化时间
for i := 0; i < b.N; i++ {
Fib(20)
}
}
  • b.ResetTimer():重置计时与内存统计,排除之前的准备时间
  • b.StopTimer() / b.StartTimer():成对使用,在循环内临时暂停计时(少用,开销大)

避免编译器优化——编译器可能发现结果未使用而直接删掉被测代码:

1
2
3
4
5
6
7
8
9
var globalResult int // 包级变量,编译器无法证明它无副作用

func BenchmarkAdd(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
r = Add(2, 3)
}
globalResult = r // 写到包级变量,防止整段被优化掉
}

常见陷阱

  1. 被优化掉:未消费的结果可能被编译器消除,务必赋给包级变量或用 runtime.KeepAlive
  2. 未排除初始化:忘了 b.ResetTimer(),导致首次构造数据的开销污染结果
  3. 波动过大:单次测量不可信,务必加 -count=5~10,必要时用 -benchtime 拉长
  4. 忘了 -run=^$:单元测试的耗时被算进基准报告

模糊测试(Fuzz Testing,Go 1.18+)

概念

模糊测试让框架自动生成大量随机输入喂给被测函数,目标是发现人工难以预料的边界输入导致的 panic 或断言失败。它区别于普通测试的关键在于:输入空间不再由人手写,而是由引擎基于种子语料变异生成。

  • 种子语料:通过 f.Add() 显式添加,通常提交到代码库,作为变异起点
  • 生成语料:引擎在 fuzzing 过程中产生的语料,默认存于 $GOCACHE/fuzz/,失败用例会落盘到 testdata/fuzz/<FuzzName>/

编写方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func FuzzReverse(f *testing.F) {
// 种子语料:提供几个典型输入作为变异起点
f.Add("hello")
f.Add("中文")
f.Add("")

f.Fuzz(func(t *testing.T, s string) {
rev := Reverse(s)
double := Reverse(rev)
// 不变量:翻转两次应得到原串
if double != s {
t.Errorf("Reverse twice: %q != %q", double, s)
}
})
}

f.Fuzz 的回调函数签名中,第一个参数固定是 *testing.T,其后是 fuzzing 的目标参数类型(上例为 string)。

运行与复现

1
2
go test -fuzz=FuzzReverse       # 运行指定模糊测试(持续运行直到失败或 Ctrl+C)
go test -fuzz=. -fuzztime=30s # 运行所有模糊测试 30 秒

普通 go test(不带 -fuzz)只会用种子语料跑模糊函数,相当于普通测试;只有带 -fuzz 才会进入随机变异模式。

当引擎发现失败用例,会把它写入 testdata/fuzz/FuzzReverse/<hash>,并在输出中给出复现命令。之后用普通测试即可复现:

1
go test -run=FuzzReverse/<hash>

这个失败用例文件应提交到代码库,作为回归用例长期保留。

测试覆盖率

1
2
3
4
5
go test -cover                    # 显示覆盖率百分比
go test -coverprofile=cover.out # 生成覆盖率文件
go test -covermode=atomic # 指定覆盖率统计模式
go tool cover -html=cover.out # 生成 HTML 可视化报告(浏览器打开)
go tool cover -func=cover.out # 按函数列出覆盖率

覆盖率模式(-covermode)

模式说明
set(默认)仅记录每条语句是否被执行
count记录每条语句被执行的次数
atomiccount 的并发安全版本,开启 -race 时必须用

跨包统计覆盖率用 -coverpkg

1
go test -coverprofile=cover.out -coverpkg=./... ./...

竞态检测器

Go 内建数据竞争检测器,能在运行时发现对共享变量的并发访问冲突:

1
go test -race ./...

它基于 ThreadSanitizer,会在测试运行时插桩,一旦发现两个 goroutine 无同步地访问同一变量且至少一个是写操作,就报告竞争。代价是运行时间增加数倍、内存占用上升,因此建议:

  • 本地开发时按需开启
  • CI 中默认开启,作为常规质量门禁

构建产物也可带 -racego build -race,用于线上灰度排查偶发并发问题(但生产常驻会有性能损耗)。

Mock 与依赖注入

Go 社区普遍不依赖 mock 框架,而是通过接口 + 依赖注入让代码可测:被测代码依赖接口,测试时传入一个手写或生成的假实现(fake/mock)。

1
2
3
4
5
6
7
8
9
10
11
12
// 被测代码依赖接口,而非具体实现
type Sender interface {
Send(to, msg string) error
}

type Service struct {
sender Sender
}

func (s *Service) Notify(to, msg string) error {
return s.sender.Send(to, msg)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 测试时用一个手写的 fake 替换真实依赖
type fakeSender struct {
sent []string
err error
}

func (f *fakeSender) Send(to, msg string) error {
f.sent = append(f.sent, to+":"+msg)
return f.err
}

func TestNotify(t *testing.T) {
fs := &fakeSender{}
s := &Service{sender: fs}

if err := s.Notify("alice", "hello"); err != nil {
t.Fatalf("Notify failed: %v", err)
}
if len(fs.sent) != 1 || fs.sent[0] != "alice:hello" {
t.Errorf("unexpected sends: %v", fs.sent)
}
}

当接口方法多、调用复杂时,可借助代码生成工具减少样板:

  • gomock:官方维护,基于 mockgen 生成,支持严格调用顺序校验
  • moq:根据接口定义生成结构体 mock
  • testify/mock:手动编排,运行时断言调用

第三方测试库 testify

testify 是 Go 生态最流行的断言库,提供比标准库 t.Errorf 更丰富的断言与失败处理。核心子包:

  • assert:失败后继续执行
  • require:失败立即停止(等价 t.Fatal
  • mock:接口 mock
  • suite:测试套件(setup/teardown 钩子)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAdd(t *testing.T) {
// assert:失败继续,适合聚合多个断言
assert.Equal(t, 5, Add(2, 3))
assert.NotEqual(t, 0, Add(2, 3))

// require:失败立即停止,适合前置条件
result, err := Parse("42")
require.NoError(t, err) // err 非 nil 则直接终止
assert.Equal(t, 42, result)
}

testify 是"语法糖"而非必需。团队是否引入取决于偏好:它能让断言更简洁,但标准库 t.Errorf 配合表驱动测试已能覆盖绝大多数场景。

常用最佳实践总结

  • 测试文件放在被测包同目录,命名 _test.go;优先用 foo_test 外部包测试
  • 单元测试优先采用表驱动结构,新增用例只改数据
  • 耗时用例用 t.Parallel() 并行,注意循环变量捕获
  • t.Cleanup / t.Setenv / t.TempDir 替代裸 defer,更好适配子测试
  • 基准测试务必加 -run=^$ 避免单元测试干扰
  • 重要性能代码加 -benchmem 观察内存分配,用 b.ReportAllocs() 精确定位
  • -count=5~10 多跑几次降低测量误差,关键路径纳入 CI 防止性能回归
  • 模糊测试用于解析、编解码、序列化等输入边界敏感的场景,失败用例落盘长期保留
  • CI 中默认开启 -race 检测数据竞争
  • 覆盖率目标一般 ≥80%,关键包争取更高,但不要为追指标而写无意义测试