Go 测试体系:单元测试、Benchmark、Fuzz 与 Race Detector
Go 把测试、基准测试、模糊测试、覆盖率和竞态检测统一在工具链中。高质量测试不仅验证返回值,还应覆盖错误语义、边界输入、并发安全和资源生命周期。
测试文件与基本结构
测试文件以 _test.go 结尾,测试函数以 Test 开头:
package calculator
import "testing"
func Add(a, b int) int {
return a + b
}
func TestAdd(t *testing.T) {
got := Add(2, 3)
if got != 5 {
t.Fatalf("Add(2, 3) = %d, want 5", got)
}
}
常用命令:
go test ./...
go test -v ./...
go test -run TestAdd ./...
go test -count=1 ./...
-count=1 禁用测试结果缓存,排查依赖时间、随机数或外部状态的问题时很有用。
表驱动测试
多个输入共享同一行为时,用测试表集中表达用例:
func TestParsePort(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{name: "minimum", input: "1", want: 1},
{name: "normal", input: "8080", want: 8080},
{name: "maximum", input: "65535", want: 65535},
{name: "zero", input: "0", wantErr: true},
{name: "too large", input: "65536", wantErr: true},
{name: "not a number", input: "http", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := ParsePort(test.input)
if test.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != test.want {
t.Fatalf("got %d, want %d", got, test.want)
}
})
}
}
用例名称应描述场景,而不是简单写 case1。测试失败时可以直接定位业务条件。
子测试与并行执行
互不共享可变状态的用例可以并行运行:
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
// test body
})
}
旧版本 Go 中需要在循环内重新绑定变量,避免闭包捕获同一个迭代变量。即使使用新版本,显式绑定也有助于代码兼容和可读性。
不要对修改全局变量、环境变量或共享数据库的测试随意使用 t.Parallel()。
测试辅助函数
重复断言可以提取为辅助函数,并调用 t.Helper(),使失败行号指向调用位置:
func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Fatalf("got %v, want %v", got, want)
}
}
临时目录由测试框架管理:
func TestSave(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := Save(path, Config{Name: "demo"}); err != nil {
t.Fatal(err)
}
}
环境变量可使用 t.Setenv,测试结束后会自动恢复:
t.Setenv("APP_ENV", "test")
验证错误语义
不要依赖完整错误文本。对于包装错误,使用 errors.Is:
func TestRepository_NotFound(t *testing.T) {
_, err := repository.Find(context.Background(), 404)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
自定义错误使用 errors.As:
var validationErr *ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T", err)
}
assertEqual(t, validationErr.Field, "email")
通过依赖注入隔离外部系统
服务依赖调用方定义的小接口,测试可以提供内存实现:
type UserStore interface {
FindByEmail(context.Context, string) (User, error)
Save(context.Context, User) error
}
type fakeUserStore struct {
findUser User
findErr error
saved []User
}
func (f *fakeUserStore) FindByEmail(context.Context, string) (User, error) {
return f.findUser, f.findErr
}
func (f *fakeUserStore) Save(_ context.Context, user User) error {
f.saved = append(f.saved, user)
return nil
}
Fake 适合表达状态和业务行为;Mock 适合验证调用协议。不要为了使用 Mock 而给每个结构体都创建接口。
HTTP Handler 测试
httptest 可以在内存中构造请求和响应:
func TestHealthHandler(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
recorder := httptest.NewRecorder()
HealthHandler(recorder, request)
response := recorder.Result()
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK)
}
}
需要测试真实连接、重定向或客户端行为时,使用 httptest.NewServer:
server := httptest.NewServer(handler)
defer server.Close()
response, err := server.Client().Get(server.URL + "/users/1")
集成测试与测试数据库
集成测试用于验证真实驱动、SQL 约束和迁移。应满足以下条件:
- 每次测试拥有隔离的数据或数据库。
- 测试能独立运行,不依赖执行顺序。
- 测试结束后清理资源。
- 外部依赖不可用时给出明确失败或按约定跳过。
可以通过构建标签分离慢速集成测试:
//go:build integration
package repository_test
运行:
go test -tags=integration ./...
不要让默认单元测试依赖开发者机器上偶然运行的数据库。
Benchmark:测量性能和分配
基准函数以 Benchmark 开头:
func BenchmarkEncode(b *testing.B) {
value := Payload{ID: 1, Name: "benchmark"}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := json.Marshal(value)
if err != nil {
b.Fatal(err)
}
}
}
运行:
go test -run '^$' -bench . -benchmem ./...
go test -run '^$' -bench BenchmarkEncode -count 10 ./...
准备数据和启动依赖不应计入目标代码的耗时。使用 b.ResetTimer 或 b.StopTimer、b.StartTimer 控制计时范围。
比较优化前后结果时,可以使用 benchstat 分析多轮基准数据,避免根据单次波动下结论。
Fuzz:探索未知输入
模糊测试会不断变异种子输入,寻找 panic、越界、死循环和不满足的性质。
func FuzzParseToken(f *testing.F) {
f.Add("user:123")
f.Add("")
f.Add("invalid")
f.Fuzz(func(t *testing.T, input string) {
token, err := ParseToken(input)
if err != nil {
return
}
encoded := token.String()
again, err := ParseToken(encoded)
if err != nil {
t.Fatalf("round trip failed: %v", err)
}
if again != token {
t.Fatalf("round trip changed token")
}
})
}
运行一段时间:
go test -fuzz=FuzzParseToken -fuzztime=30s ./...
Fuzz 的断言应验证稳定性质,例如编解码往返、解析后重新格式化、排序结果有序或函数永不 panic。
Race Detector:发现数据竞争
数据竞争发生在多个 Goroutine 并发访问同一变量,且至少一个访问是写操作,同时缺少同步。
go test -race ./...
go test -race -count=10 ./internal/cache
Race Detector 只能发现实际执行路径上的竞争,因此需要测试覆盖关键并发场景。它不能证明程序没有死锁或逻辑竞态。
常见修复方式:
- 使用
sync.Mutex或sync.RWMutex保护复合状态。 - 使用
sync/atomic操作简单计数器和标志。 - 通过 Channel 把状态交给单个 Goroutine 管理。
- 复制不可变数据,避免共享可变对象。
覆盖率的正确使用
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
go tool cover -html=coverage.out
覆盖率只能说明代码是否执行过,不能说明断言是否有效。优先覆盖以下路径:
- 核心业务规则。
- 输入边界和错误分支。
- 事务提交与回滚。
- 超时和取消。
- 并发访问与资源清理。
不要为了提高数字编写没有行为价值的测试。
可测试代码的设计原则
- 把时间、随机数和外部 I/O 放在可替换边界。
- 业务逻辑依赖小接口,而不是具体数据库或 HTTP 客户端。
- 函数返回稳定错误语义,不要求测试解析日志文本。
- 避免包级可变状态和隐藏初始化副作用。
- 将纯计算与 I/O 分开,使大部分行为能在单元测试中验证。
CI 建议
基础检查可以包含:
go test ./...
go test -race ./...
go vet ./...
go test -coverprofile=coverage.out ./...
基准测试通常不作为每次提交的硬门禁,但关键路径可以定期运行并保存历史趋势。Fuzz 可以在夜间任务或合并前任务中运行更长时间。
实践检查清单
- 核心函数是否有正常、边界和错误用例。
- 相似输入是否使用可读的表驱动测试。
- 测试是否独立于执行顺序和开发机状态。
- HTTP、数据库和文件边界是否选择了合适的测试层级。
- 错误断言是否使用
errors.Is或errors.As。 - 性能改动是否有可重复的 Benchmark 数据。
- 解析器和协议边界是否适合加入 Fuzz。
- 并发代码是否在 CI 中运行 Race Detector。
- 覆盖率是否用于发现遗漏,而不是单纯追求数字。
测试体系的目标是降低变更风险。单元测试保证规则,集成测试验证边界,Benchmark 约束性能,Fuzz 探索未知输入,Race Detector 检查并发访问,它们共同构成可持续演进的反馈系统。