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.ResetTimerb.StopTimerb.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.Mutexsync.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.Iserrors.As
  6. 性能改动是否有可重复的 Benchmark 数据。
  7. 解析器和协议边界是否适合加入 Fuzz。
  8. 并发代码是否在 CI 中运行 Race Detector。
  9. 覆盖率是否用于发现遗漏,而不是单纯追求数字。

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