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

覆盖率只能说明代码是否执行过,不能说明断言是否有效。优先覆盖以下路径:

  • 核心业务规则。
  • 输入边界和错误分支。
  • 事务提交与回滚。
  • 超时和取消。
  • 并发访问与资源清理。

不要为了提高数字编写没有行为价值的测试。

可测试代码的设计原则

  1. 把时间、随机数和外部 I/O 放在可替换边界。
  2. 业务逻辑依赖小接口,而不是具体数据库或 HTTP 客户端。
  3. 函数返回稳定错误语义,不要求测试解析日志文本。
  4. 避免包级可变状态和隐藏初始化副作用。
  5. 将纯计算与 I/O 分开,使大部分行为能在单元测试中验证。

CI 建议

基础检查可以包含:

go test ./...
go test -race ./...
go vet ./...
go test -coverprofile=coverage.out ./...

基准测试通常不作为每次提交的硬门禁,但关键路径可以定期运行并保存历史趋势。Fuzz 可以在夜间任务或合并前任务中运行更长时间。

实践检查清单

  1. 核心函数是否有正常、边界和错误用例。
  2. 相似输入是否使用可读的表驱动测试。
  3. 测试是否独立于执行顺序和开发机状态。
  4. HTTP、数据库和文件边界是否选择了合适的测试层级。
  5. 错误断言是否使用 errors.Is 或 errors.As。
  6. 性能改动是否有可重复的 Benchmark 数据。
  7. 解析器和协议边界是否适合加入 Fuzz。
  8. 并发代码是否在 CI 中运行 Race Detector。
  9. 覆盖率是否用于发现遗漏,而不是单纯追求数字。

测试体系的目标是降低变更风险。单元测试保证规则,集成测试验证边界,Benchmark 约束性能,Fuzz 探索未知输入,Race Detector 检查并发访问,它们共同构成可持续演进的反馈系统。