Skip to content

Go 测试:从单元测试到可观测的质量体系

面向有 Java 经验的开发者,基于 Go 1.26。

Go 没有为测试另造一套庞大的框架。编译、运行、缓存、覆盖率、竞态检测、模糊测试和基准测试都由 go test 统一调度,测试代码依旧是普通的 Go 代码。这套设计看起来朴素,却抓住了测试最重要的一件事:结果能否稳定复现。测试文件跟着包走,依赖写进 go.mod,同一条命令可以在开发机和 CI 中执行。

只会写 func TestXxx(t *testing.T) 还不够。测试要长期维护,就绕不开边界划分、数据构造、时间与并发、外部依赖、失败诊断、基准偏差、覆盖率误读和不稳定测试。下面从最小示例开始,把这些问题逐个放回真实的测试流程中。

目录

1. 测试文件与执行模型

测试文件以 _test.go 结尾,和被测代码放在同一个 package 目录。它们不会进入普通的 go build 产物,只会由 go test 编译。

text
money/
├── money.go
├── money_test.go
└── example_test.go

go test 大致会做这些事:

  1. 加载目标 package 及其依赖;
  2. 编译 package;
  3. 编译 _test.go 文件和由工具生成的测试入口;
  4. 运行测试、示例、模糊测试的种子语料和基准测试中被选中的部分;
  5. 根据源码、环境和参数决定是否复用缓存结果。

常用执行范围:

bash
go test                 # 当前 package
go test ./...           # 当前 module 下所有 package
go test ./internal/pay  # 指定 package
go test -run TestParse  # 用正则筛选测试名
go test -v ./...        # 输出每个测试和日志
go test -count=1 ./...  # 不复用成功结果缓存

go test ./...... 是 package pattern,不是 shell 的文件通配符。它会递归匹配当前 module 下的 package,但不会自动跨进嵌套 module。

2. 编写第一个单元测试

被测代码:

go
package money

import (
	"errors"
	"math"
)

var ErrInvalidRate = errors.New("rate must be finite and non-negative")

func ApplyDiscount(cents int64, rate float64) (int64, error) {
	if rate < 0 || rate > 1 || math.IsNaN(rate) || math.IsInf(rate, 0) {
		return 0, ErrInvalidRate
	}
	return cents - int64(math.Round(float64(cents)*rate)), nil
}

测试函数必须满足三个条件:

  • 名字以 Test 开头,后面的首字符不是小写字母;
  • 参数只有一个:t *testing.T
  • 没有返回值。
go
package money

import "testing"

func TestApplyDiscount(t *testing.T) {
	got, err := ApplyDiscount(10_000, 0.2)
	if err != nil {
		t.Fatalf("ApplyDiscount() error = %v", err)
	}

	const want int64 = 8_000
	if got != want {
		t.Errorf("ApplyDiscount() = %d, want %d", got, want)
	}
}

测试失败信息至少要回答三个问题:调用了什么、实际得到什么、期望什么。如果只写 failed结果错误,失败后还得重新调试一遍,日志本身几乎没有价值。

ErrorFatalFailNow

  • t.Error / t.Errorf:记录失败,当前测试继续执行;
  • t.Fatal / t.Fatalf:记录失败,并通过 runtime.Goexit 终止当前测试 goroutine;
  • t.Fail:只把测试标为失败;
  • t.FailNow:把测试标为失败并终止当前测试 goroutine。

前置条件不成立时用 Fatalf,多个彼此独立的断言可以用 Errorf

go
user, err := loadUser()
if err != nil {
	t.Fatalf("loadUser() error = %v", err)
}

if user.Name != "Alice" {
	t.Errorf("Name = %q, want %q", user.Name, "Alice")
}
if user.Active != true {
	t.Errorf("Active = %v, want true", user.Active)
}

不要从测试创建的其他 goroutine 调用 FatalfFailNow。它们只终止调用者所在的 goroutine,并不会按预期终止主测试流程。工作 goroutine 应把结果或错误通过 channel 传回测试 goroutine。

3. 表驱动测试

同一个行为需要覆盖多个输入时,表驱动测试比复制多个函数更容易维护:

go
func TestApplyDiscount(t *testing.T) {
	tests := []struct {
		name    string
		cents   int64
		rate    float64
		want    int64
		wantErr error
	}{
		{
			name:  "twenty percent",
			cents: 10_000,
			rate:  0.2,
			want:  8_000,
		},
		{
			name:  "round to nearest cent",
			cents: 999,
			rate:  0.1,
			want:  899,
		},
		{
			name:    "negative rate",
			cents:   100,
			rate:    -0.1,
			wantErr: ErrInvalidRate,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := ApplyDiscount(tt.cents, tt.rate)

			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("error = %v, want %v", err, tt.wantErr)
			}
			if err == nil && got != tt.want {
				t.Errorf("result = %d, want %d", got, tt.want)
			}
		})
	}
}

测试表应该保存“一个用例真正变化的东西”。如果每行塞进十几个布尔开关,测试表反而会隐藏意图。复杂场景可以给每个用例一个 preparecheck 函数,但不要为了追求统一格式把简单断言变成晦涩的元编程。

用例名会成为完整测试路径的一部分:

text
TestApplyDiscount/twenty_percent
TestApplyDiscount/negative_rate

可以单独运行:

bash
go test -run 'TestApplyDiscount/negative_rate'

4. 子测试与并行测试

t.Run 创建子测试。父测试可以组织共享准备工作,也可以让子测试并行:

go
func TestParser(t *testing.T) {
	tests := []struct {
		name string
		in   string
		want int
	}{
		{"zero", "0", 0},
		{"positive", "42", 42},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			got, err := strconv.Atoi(tt.in)
			if err != nil {
				t.Fatal(err)
			}
			if got != tt.want {
				t.Errorf("Atoi(%q) = %d, want %d", tt.in, got, tt.want)
			}
		})
	}
}

从 Go 1.22 开始,for range 每次迭代创建新的迭代变量,因此在 module 的语言版本至少为 1.22 时,上例闭包捕获 tt 是安全的。维护旧 module 时仍要看 go.modgo 行,不能只看本机工具链版本。

并行测试不是默认优化按钮。只有这些条件满足时才适合调用 t.Parallel()

  • 用例不修改进程级环境;
  • 不依赖全局时钟、全局 logger 或单例;
  • 临时文件和监听端口相互隔离;
  • 被测依赖允许并发访问;
  • 用例之间没有隐含顺序。

并行子测试会先暂停,等顺序部分和父测试到达合适阶段后再调度。可以利用这一点实现分组:

go
func TestGrouped(t *testing.T) {
	t.Run("parallel group", func(t *testing.T) {
		for _, name := range []string{"a", "b", "c"} {
			t.Run(name, func(t *testing.T) {
				t.Parallel()
				// ...
			})
		}
	})
	// 到这里时,组内子测试已经完成。
}

5. 失败、日志与辅助函数

5.1 日志

t.Logt.Logf 在测试失败或使用 -v 时显示:

go
t.Logf("request id=%s", requestID)

日志是诊断信息,不应替代断言。不要让测试依赖人工阅读日志来判断成功与否。

5.2 t.Helper

辅助函数调用 t.Helper() 后,失败位置会指向调用辅助函数的测试行,而不是辅助函数内部:

go
func mustReadFile(t *testing.T, path string) []byte {
	t.Helper()

	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read %q: %v", path, err)
	}
	return data
}

测试辅助函数通常接收 testing.TB,这样既能用于测试,也能用于基准:

go
func mustTempFile(tb testing.TB, content []byte) string {
	tb.Helper()

	path := filepath.Join(tb.TempDir(), "input.txt")
	if err := os.WriteFile(path, content, 0o600); err != nil {
		tb.Fatal(err)
	}
	return path
}

testing.TB*testing.T*testing.B 等类型共同实现的接口。

6. 测试包应该放在包内还是包外

测试可以使用两种 package:

go
package money       // 白盒测试
package money_test  // 黑盒测试

包内测试能访问未导出标识符,适合验证很难从公共 API 观察到的内部算法。包外测试只能使用导出的 API,更接近真实调用者,也能发现包的 API 是否难用、是否存在导入循环。

实际项目可以混用:

text
money/
├── money.go
├── money_internal_test.go  // package money
└── money_test.go           // package money_test

优先从公共行为测试。只有当内部算法复杂、公共路径难以覆盖故障定位,或确实需要验证不变量时,再写包内测试。测试紧贴实现细节会让正常重构产生大量无意义修改。

7. 依赖注入与测试替身

Go 没有强制使用 mock 框架。小接口、函数值和普通 struct 往往已经足够。

7.1 在使用方定义小接口

go
type UserStore interface {
	FindByID(ctx context.Context, id int64) (User, error)
}

type Service struct {
	store UserStore
}

手写 stub:

go
type storeStub struct {
	find func(context.Context, int64) (User, error)
}

func (s storeStub) FindByID(ctx context.Context, id int64) (User, error) {
	return s.find(ctx, id)
}

测试:

go
func TestServiceProfile(t *testing.T) {
	store := storeStub{
		find: func(_ context.Context, id int64) (User, error) {
			if id != 7 {
				t.Fatalf("id = %d, want 7", id)
			}
			return User{ID: id, Name: "Alice"}, nil
		},
	}

	svc := Service{store: store}
	got, err := svc.Profile(context.Background(), 7)
	if err != nil {
		t.Fatal(err)
	}
	if got.Name != "Alice" {
		t.Errorf("Name = %q, want Alice", got.Name)
	}
}

接口应由需要它的代码定义,而不是由基础设施包为了“方便 mock”提前定义一个包含几十个方法的大接口。

7.2 函数依赖

只有一个操作时,函数值更直接:

go
type Clock func() time.Time

type TokenIssuer struct {
	now Clock
}

生产环境传 time.Now,测试传固定函数。这个设计比修改全局时钟安全,也允许并行测试。

7.3 fake、stub 与 mock

  • stub:按预设返回结果;
  • fake:有简化但可工作的实现,例如内存仓库;
  • mock:不仅返回结果,还验证调用次数、顺序和参数;
  • spy:记录调用,测试结束后检查。

不要把所有替身都叫 mock,更不要对每次函数调用都做严格顺序验证。过度交互验证会把测试绑定到实现步骤,而不是业务结果。

8. HTTP、文件、环境变量与时间

8.1 HTTP handler

httptest.NewRecorder 可以直接测试 handler,不需要真实监听端口:

go
func TestHealthHandler(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/health", nil)
	rec := httptest.NewRecorder()

	healthHandler(rec, req)

	res := rec.Result()
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK)
	}
}

8.2 HTTP client

httptest.NewServer 创建一个只在测试期间存在的本地服务器:

go
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	io.WriteString(w, `{"name":"Alice"}`)
}))
t.Cleanup(server.Close)

client := NewClient(server.URL, server.Client())

代码应允许注入 *http.Client。直接使用 http.DefaultClient 会让超时、transport 和测试隔离都难以控制。

8.3 临时目录

go
dir := t.TempDir()
path := filepath.Join(dir, "config.json")

测试结束后目录会自动清理。不要把临时文件写到源码目录,也不要用固定的 /tmp/test.txt;并行测试和 CI 很容易相互覆盖。

8.4 环境变量与工作目录

go
t.Setenv("APP_ENV", "test")

测试结束后环境变量自动恢复。因为环境变量是进程级共享状态,调用 Setenv 的测试不能与其他可能观察该变量的测试并行。

工作目录可以通过 os.Chdir 修改,但同样属于进程级状态。更稳妥的设计是把根目录作为参数传给业务代码。

8.5 时间

不要用长时间 time.Sleep 等待并发结果:

go
select {
case got := <-result:
	// 断言 got
case <-time.After(500 * time.Millisecond):
	t.Fatal("timed out waiting for result")
}

超时是最后一道防死锁保护,不是同步机制。更好的被测 API 应暴露可等待的结果或接收 context.Context

需要控制当前时间时注入函数或小接口:

go
type Clock interface {
	Now() time.Time
}

不要在业务包里用可变全局变量替换 time.Now,否则并行测试和真实并发调用都会产生竞态。

9. 清理资源与 Go 1.26 测试产物

9.1 t.Cleanup

go
db := openTestDB(t)
t.Cleanup(func() {
	if err := db.Close(); err != nil {
		t.Errorf("close database: %v", err)
	}
})

清理函数按后进先出顺序执行,即使测试调用了 Fatal 也会运行。构造资源的辅助函数应该顺手注册清理,这样调用方不容易忘记。

deferCleanup 都能释放资源,区别是:

  • defer 绑定当前辅助函数的返回;
  • t.Cleanup 绑定整个测试或子测试的结束。

辅助函数创建并返回资源时,通常使用 Cleanup

9.2 ArtifactDir

Go 1.26 为 TBF 增加了 ArtifactDir。测试可以把失败截图、协议转储或性能结果写进去:

go
func TestRender(t *testing.T) {
	got := renderPage()
	if !valid(got) {
		path := filepath.Join(t.ArtifactDir(), "rendered.html")
		if err := os.WriteFile(path, got, 0o600); err != nil {
			t.Fatal(err)
		}
		t.Fatalf("rendered output is invalid; artifact: %s", path)
	}
}

默认情况下产物位于测试临时目录并在结束后删除。传入 -artifacts 后,产物会保留在 -outputdir 指定的目录(未指定时是当前目录):

bash
go test -artifacts -outputdir ./test-artifacts ./...

产物目录适合保存诊断文件,不应该成为测试间交换数据的隐式通道。

10. 比较复杂结果

简单可比较值直接使用 ==。slice、map 或包含它们的 struct 不能直接比较。

10.1 手工比较

业务对象字段较少时,手工比较最清楚:

go
if got.ID != want.ID || got.Name != want.Name {
	t.Errorf("user = %#v, want %#v", got, want)
}

10.2 标准库辅助

go
if !slices.Equal(gotIDs, wantIDs) {
	t.Errorf("ids = %v, want %v", gotIDs, wantIDs)
}

if !maps.Equal(gotCounts, wantCounts) {
	t.Errorf("counts = %v, want %v", gotCounts, wantCounts)
}

10.3 reflect.DeepEqual

reflect.DeepEqual 可用于通用深比较,但语义不一定符合业务直觉。例如 nil slice 和空 slice 不相等,函数值除了都为 nil 外不相等。它只告诉你“不相等”,不会生成好读的差异。

第三方 go-cmp 在大型项目中很常见,因为它能输出结构化 diff,并允许配置忽略字段或自定义比较。引入前仍要明确比较语义,不能把工具默认行为当业务契约。

Golden file 适合大段稳定输出:

go
golden := filepath.Join("testdata", "invoice.golden")
want, err := os.ReadFile(golden)
if err != nil {
	t.Fatal(err)
}
if diff := bytes.Compare(got, want); diff != 0 {
	t.Fatalf("invoice differs from %s", golden)
}

更新 golden 文件应使用显式参数,例如 -update,不能在普通测试失败时自动覆盖期望结果。

11. 错误、panic 与并发测试

11.1 错误

不要比较错误字符串:

go
if !errors.Is(err, ErrInvalidRate) {
	t.Fatalf("error = %v, want ErrInvalidRate", err)
}

需要检查结构化字段时使用 errors.As

go
var parseErr *ParseError
if !errors.As(err, &parseErr) {
	t.Fatalf("error type = %T, want *ParseError", err)
}
if parseErr.Offset != 4 {
	t.Errorf("Offset = %d, want 4", parseErr.Offset)
}

11.2 panic

只有 API 契约明确承诺 panic 时才测试 panic。业务输入错误一般应该返回 error

go
func assertPanics(t *testing.T, f func()) {
	t.Helper()
	defer func() {
		if recover() == nil {
			t.Fatal("expected panic")
		}
	}()
	f()
}

recover 只能捕获同一个 goroutine 中正在展开的 panic。被测 goroutine 的 panic 不会被测试 goroutine 的 recover 捕获。

11.3 并发和竞态

bash
go test -race ./...

竞态检测器只发现实际执行路径上的竞态,因此它不能替代覆盖足够场景的并发测试。必要时用真实压力运行带 -race 构建的程序。

并发测试应验证可观察的不变量,不要依赖 goroutine 的具体调度顺序:

go
func TestCounterConcurrent(t *testing.T) {
	var c Counter
	var wg sync.WaitGroup

	for range 100 {
		wg.Go(func() {
			for range 1_000 {
				c.Inc()
			}
		})
	}
	wg.Wait()

	if got, want := c.Value(), int64(100_000); got != want {
		t.Fatalf("Value() = %d, want %d", got, want)
	}
}

如果 module 需要兼容没有 WaitGroup.Go 的旧 Go 版本,就使用 Addgodefer Done 的传统写法。

12. 覆盖率

bash
go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
go tool cover -html=coverage.out

覆盖率说明哪些语句被执行过,不说明断言是否正确,也不说明边界、并发时序和失败路径是否经过充分验证。高覆盖率可以由“调用了所有代码但没有有效断言”的测试制造出来。

合理用法:

  • 找到长期没被执行的分支;
  • 观察关键包覆盖率是否意外下降;
  • 帮助审查新增代码是否缺少错误路径测试;
  • 不把全仓库单一百分比当作质量目标。

跨 package 统计:

bash
go test -coverpkg=./... -coverprofile=coverage.out ./...

集成测试可以使用 Go 1.20 起提供的二进制覆盖率:

bash
go build -cover -o ./bin/app ./cmd/app
GOCOVERDIR=./coverage-data ./bin/app
go tool covdata percent -i=./coverage-data
go tool covdata textfmt -i=./coverage-data -o=integration.out

13. 模糊测试

模糊测试通过覆盖率引导不断变异输入,特别适合解析器、编解码器、协议、压缩、校验和安全边界。

go
func FuzzRoundTrip(f *testing.F) {
	f.Add("hello")
	f.Add("中文")
	f.Add("")

	f.Fuzz(func(t *testing.T, input string) {
		encoded := Encode(input)
		decoded, err := Decode(encoded)
		if err != nil {
			t.Fatalf("Decode(Encode(%q)): %v", input, err)
		}
		if decoded != input {
			t.Fatalf("round trip = %q, want %q", decoded, input)
		}
	})
}

普通 go test 会运行 f.Add 添加的种子和已保存的回归语料。真正开始变异:

bash
go test -fuzz=FuzzRoundTrip -fuzztime=30s

发现失败后,输入会写到 testdata/fuzz/FuzzRoundTrip,以后普通测试也会复现它。确认失败代表真实 bug 后,应把语料提交到仓库。

好的 fuzz 不变量包括:

  • 不应 panic;
  • 编码后解码得到原值;
  • 规范化操作具有幂等性;
  • 两个独立实现结果一致;
  • 输出长度、权限或资源消耗不超过上限。

模糊目标必须足够快且结果确定。不要默认访问网络、依赖当前时间或创建无限量资源。攻击者可控长度的输入要先限制,否则测试可能只是在反复触发内存耗尽。

14. 基准测试

传统基准:

go
func BenchmarkEncode(b *testing.B) {
	input := strings.Repeat("go", 128)
	b.ReportAllocs()

	for i := 0; i < b.N; i++ {
		_ = Encode(input)
	}
}

Go 1.24 起推荐 B.Loop

go
func BenchmarkEncode(b *testing.B) {
	input := strings.Repeat("go", 128)
	b.ReportAllocs()

	for b.Loop() {
		_ = Encode(input)
	}
}

Go 1.26 修正了 B.Loop 循环体阻止内联的问题,通常可以从 b.N 迁移到 b.Loop。运行:

bash
go test -bench=. -benchmem ./...
go test -run='^$' -bench=BenchmarkEncode -count=10 > old.txt

基准结果常见字段:

text
BenchmarkEncode-12  820000  1420 ns/op  512 B/op  2 allocs/op

含义分别是基准名及 GOMAXPROCS、迭代次数、每次耗时、每次分配字节数、每次分配次数。

子基准:

go
for _, size := range []int{16, 256, 4096} {
	b.Run(strconv.Itoa(size), func(b *testing.B) {
		input := make([]byte, size)
		for b.Loop() {
			_ = Hash(input)
		}
	})
}

基准必须防止测到无关工作:

  • 输入构造放到计时循环外;
  • 必要时使用 b.ResetTimerb.StopTimerb.StartTimer
  • I/O 基准设置 b.SetBytes
  • 控制 CPU 频率、后台负载和编译器版本;
  • 多次运行并用统计工具比较,不凭一次结果下结论;
  • 避免让编译器把整个计算优化掉。

15. 示例测试与文档

Example 既是测试,也是 go doc 和 pkg.go.dev 可展示的可执行示例:

go
func ExampleApplyDiscount() {
	got, err := ApplyDiscount(10_000, 0.2)
	fmt.Println(got, err)
	// Output:
	// 8000 <nil>
}

输出注释是断言。无序输出使用:

go
// Unordered output:
// apple
// banana

命名规则:

go
func Example() {}
func ExampleApplyDiscount() {}
func ExampleApplyDiscount_zeroRate() {}

示例应短小、确定,不依赖网络或当前时间。它的首要读者是人,复杂场景仍应放到普通测试中。

16. 集成测试、构建标签与 TestMain

16.1 构建标签

go
//go:build integration

package repository_test

运行:

bash
go test -tags=integration ./...

标签适合明确区分需要数据库、容器或外部服务的测试。不要为了让失败测试消失而随意加标签。

16.2 TestMain

go
func TestMain(m *testing.M) {
	code := m.Run()
	os.Exit(code)
}

TestMain 适合 package 级、确实无法按测试隔离的启动工作。它只有一个 *testing.M,没有 *testing.T,失败诊断和清理不如普通测试方便。能用辅助函数和 t.Cleanup 完成时,不要升级成 TestMain

16.3 外部系统

集成测试应做到:

  • 连接信息显式配置;
  • 每次运行使用独立 schema、数据库名或租户;
  • 数据准备可重放;
  • 测试结束清理;
  • 设置总超时;
  • 失败时保留必要日志和产物;
  • 不把开发者本机已有服务当成隐式前提。

17. 测试缓存、随机性与不稳定测试

成功的 package 测试可能被缓存:

text
ok   example.com/app/user  (cached)

缓存是正常能力。需要确认重复运行时使用:

bash
go test -count=1 ./...

测试不应依赖 map 遍历顺序、goroutine 调度、当前日期、随机端口之外的固定端口,或另一个测试留下的数据。

随机测试应记录种子:

go
seed := time.Now().UnixNano()
t.Logf("seed=%d", seed)
rng := rand.New(rand.NewSource(seed))

如果随机输入对发现边界很重要,优先考虑 fuzz;它能保存最小化后的失败输入,比只打印随机种子更适合长期回归。

排查不稳定测试:

bash
go test -run TestName -count=100
go test -race -run TestName -count=20
go test -shuffle=on -count=20 ./...

-shuffle 能暴露测试间顺序依赖,并在输出中打印可复现种子。

18. CI 中的测试分层

一条实用流水线可以分为:

  1. 格式与静态检查:gofmtgo vet
  2. 快速单元测试:go test ./...
  3. 竞态测试:go test -race ./...
  4. 集成测试:go test -tags=integration ./...
  5. 覆盖率报告;
  6. 定时 fuzz 和基准回归。

每次提交都跑分钟级 fuzz 通常不现实。可以在 PR 上运行种子语料,在夜间任务中给 fuzz 固定时间预算。基准也更适合固定硬件的专用 runner,否则共享虚拟机噪声会淹没真实变化。

测试命令应该设置总超时:

bash
go test -timeout=5m ./...

超时不能替代单个外部调用的 context deadline;它只是避免整条 CI 永远挂住。

19. 与 JUnit 的差异

JUnit 习惯Go 对应方式
@Testfunc TestXxx(t *testing.T)
参数化测试表驱动测试 + t.Run
@BeforeEach普通辅助函数
@AfterEacht.Cleanup
@BeforeAll谨慎使用 TestMain,或父测试分组
assertion libraryif + t.Errorf,也可选第三方库
Mockito小接口、函数值、手写 fake/stub
JMHtesting.B
property testing原生 fuzz

Go 没有要求把测试组织成 class。共享状态通常不是方便,而是隔离风险;把准备过程写成返回明确依赖的普通函数,测试会更容易并行和复用。

20. 常见误区

只测 happy path

至少考虑零值、边界值、非法输入、依赖失败、取消、超时和并发访问。

比较完整错误字符串

错误包装后字符串会变化。使用 errors.Iserrors.As 和结构化字段。

time.Sleep 猜异步任务完成

快机器浪费时间,慢机器偶发失败。使用 channel、WaitGroup、context 和最终超时。

测试私有实现步骤

测试每个内部函数调用次数会阻碍重构。优先断言公共行为和外部可观察副作用。

为覆盖率写无断言测试

执行到不代表验证过。覆盖率是地图,不是质量成绩。

随手 t.Parallel

全局变量、环境、工作目录、默认 HTTP transport、数据库和固定端口都会让并行测试互相污染。

基准只跑一次

纳秒级差异很容易是噪声。固定环境、多次采样、使用统计比较,并同时观察分配。

在单元测试访问真实公网

公网延迟、限流、数据变化和证书都不受控制。使用本地 fake server;把真实联调放到独立集成测试。

21. 命令速查

目标命令
当前 packagego test
所有 packagego test ./...
详细输出go test -v ./...
指定测试go test -run 'TestName/subcase'
禁用成功缓存go test -count=1 ./...
随机顺序go test -shuffle=on ./...
竞态检测go test -race ./...
覆盖率go test -cover ./...
覆盖率文件go test -coverprofile=coverage.out ./...
基准go test -bench=. -benchmem ./...
模糊测试go test -fuzz=FuzzName -fuzztime=30s
集成标签go test -tags=integration ./...
保留产物go test -artifacts -outputdir ./test-artifacts ./...
总超时go test -timeout=5m ./...

可运行示例

测试代码首先得能编译,并且每次都能稳定退出。下面三个目录都可以直接交给 go test 执行,不依赖公网、固定端口或调用顺序。

示例一:表驱动测试与子测试

第一个例子用一套测试流程覆盖整数除法的正常值、截断、负数和错误输入。任何一组失败时,报告都应该直接指出对应的数据,而不是只留下一个模糊的函数名。

被测代码:

go
// Package calc 提供可独立测试的小型计算函数。
package calc

import "fmt"

// Divide 执行整数除法。除数为零是调用者可以处理的输入错误,因此返回 error,
// 而不是 panic。调用者若不需要商,可以忽略第一个返回值。
func Divide(a, b int) (int, error) {
	if b == 0 {
		return 0, fmt.Errorf("除数不能为零")
	}
	return a / b, nil
}

测试代码:

go
package calc

import "testing"

func TestDivide(t *testing.T) {
	tests := []struct {
		name    string
		a       int
		b       int
		want    int
		wantErr bool
	}{
		// 每一行只描述输入和期望;增加边界条件时不必复制测试流程。
		{name: "整除", a: 12, b: 3, want: 4},
		{name: "整数截断", a: 7, b: 2, want: 3},
		{name: "负数", a: -12, b: 3, want: -4},
		{name: "除数为零", a: 8, b: 0, wantErr: true},
	}

	for _, tt := range tests {
		tt := tt // 明确创建本轮副本,便于将来安全地加入 t.Parallel。
		t.Run(tt.name, func(t *testing.T) {
			got, err := Divide(tt.a, tt.b)
			if (err != nil) != tt.wantErr {
				t.Fatalf("Divide(%d, %d) error = %v, wantErr = %v",
					tt.a, tt.b, err, tt.wantErr)
			}
			if !tt.wantErr && got != tt.want {
				t.Errorf("Divide(%d, %d) = %d, want %d",
					tt.a, tt.b, got, tt.want)
			}
		})
	}
}

运行:

bash
go test ./examples/ch17/table-driven -v

预期结果的关键部分:

text
=== RUN   TestDivide
=== RUN   TestDivide/整除
=== RUN   TestDivide/整数截断
=== RUN   TestDivide/负数
=== RUN   TestDivide/除数为零
--- PASS: TestDivide
PASS

拆解:

  • 表中只保存变化的输入与期望,公共的调用和断言只写一次。
  • t.Run 为每行数据创建具名子测试,可用 -run 'TestDivide/除数为零' 精确执行。
  • 先判断错误状态,再检查正常结果,避免错误路径上的零值造成误判。
  • tt := tt 让循环变量成为本轮副本。现代 Go 已改进 range 变量语义,但显式副本能清楚表达未来加入 t.Parallel 后每个闭包应持有独立用例。

修改实验:增加 a=1, b=-2 和极值用例;随后在子测试里加入 t.Parallel(),运行 go test -race 验证没有共享状态竞态。

示例二:用 httptest 测试 HTTP 协议

HTTP 测试还要同时验证路由、状态码、响应头和 JSON,但没有必要为此依赖一套外部服务。第二个例子使用本地测试服务器,把这条链路留在测试进程内。

处理器:

go
// Package greeting 提供一个很小的 HTTP Handler,便于演示端到端协议测试。
package greeting

import (
	"encoding/json"
	"net/http"
	"strings"
)

type response struct {
	Message string `json:"message"`
}

// Handler 返回 http.Handler,调用方不需要绑定真实端口即可测试。
func Handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
		name := strings.TrimSpace(r.URL.Query().Get("name"))
		if name == "" {
			http.Error(w, "缺少 name", http.StatusBadRequest)
			return
		}

		w.Header().Set("Content-Type", "application/json")
		// 对内存中的小响应直接编码即可;真实服务还应统一记录编码失败。
		_ = json.NewEncoder(w).Encode(response{Message: "你好," + name})
	})
	return mux
}

测试:

go
package greeting

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestHello(t *testing.T) {
	// NewServer 绑定本机随机端口,测试覆盖路由、HTTP 状态、响应头和 JSON,
	// 但不依赖公网,也不需要手工启动后台服务。
	server := httptest.NewServer(Handler())
	t.Cleanup(server.Close)

	resp, err := server.Client().Get(server.URL + "/hello?name=Go")
	if err != nil {
		t.Fatalf("请求测试服务: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
	}
	if got := resp.Header.Get("Content-Type"); got != "application/json" {
		t.Errorf("Content-Type = %q, want application/json", got)
	}

	var body response
	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
		t.Fatalf("解析响应: %v", err)
	}
	if body.Message != "你好,Go" {
		t.Errorf("message = %q, want %q", body.Message, "你好,Go")
	}
}

func TestHelloRejectsMissingName(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/hello", nil)
	recorder := httptest.NewRecorder()

	// Recorder 适合只测 Handler 的快速单元测试,不经过真实 TCP 连接。
	Handler().ServeHTTP(recorder, req)

	if recorder.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest)
	}
}

运行:

bash
go test ./examples/ch17/http-test -v

预期结果:

text
=== RUN   TestHello
--- PASS: TestHello
=== RUN   TestHelloRejectsMissingName
--- PASS: TestHelloRejectsMissingName
PASS

拆解:

  • httptest.NewServer 经过真实 HTTP 客户端和本地 TCP 栈,适合覆盖协议集成;NewRecorder 更轻,适合只验证 Handler。
  • server.Client() 已配置为信任测试服务器。测试 TLS 服务时可换成 httptest.NewTLSServer,不要关闭证书校验来“解决”测试问题。
  • 响应体必须关闭,否则大量测试可能耗尽连接;JSON 应解码后按字段比较,不要依赖空格或字段顺序。
  • 服务绑定随机回环端口,因此可以并行运行,也不会依赖公网。

修改实验:添加 POST /hello 用例并期待 405;再把服务改为 TLS,比较 server.Client() 与默认客户端的行为。

示例三:Fuzz 种子与性质测试

反转 Unicode 字符串时,有限的手写示例很难覆盖所有字符组合。这里不再枚举每一个期望值,而是让 Fuzz 测试验证“反转两次恢复原值”等不变量。

被测代码:

go
// Package text 提供按 Unicode 字符反转文本的函数。
package text

// Reverse 按 rune 而不是 byte 反转,避免破坏中文和 emoji 的 UTF-8 编码。
func Reverse(s string) string {
	runes := []rune(s)
	for left, right := 0, len(runes)-1; left < right; left, right = left+1, right-1 {
		runes[left], runes[right] = runes[right], runes[left]
	}
	return string(runes)
}

Fuzz 与基准:

go
package text

import (
	"testing"
	"unicode/utf8"
)

func FuzzReverse(f *testing.F) {
	// 种子语料应覆盖空串、ASCII、多字节字符和组合场景。
	// 普通 go test 会执行这些种子;-fuzz 才会在此基础上持续变异输入。
	for _, seed := range []string{"", "Go", "你好", "Go语言🙂"} {
		f.Add(seed)
	}

	f.Fuzz(func(t *testing.T, input string) {
		reversed := Reverse(input)

		// 性质 1:反转不应制造无效 UTF-8。随机字节串可能本来就无效,
		// string 转 []rune 会把无效序列替换成 RuneError,所以只约束有效输入。
		if utf8.ValidString(input) && !utf8.ValidString(reversed) {
			t.Fatalf("Reverse(%q) 产生无效 UTF-8", input)
		}

		// 性质 2:对有效 UTF-8 字符串反转两次应恢复原值。
		// 这比枚举几个期望字符串更适合发现算法的普遍性错误。
		if utf8.ValidString(input) {
			if got := Reverse(reversed); got != input {
				t.Fatalf("Reverse(Reverse(%q)) = %q", input, got)
			}
		}
	})
}

func BenchmarkReverse(b *testing.B) {
	input := "Go 泛型、并发与工程化🙂"
	for b.Loop() {
		_ = Reverse(input)
	}
}

先执行固定种子:

bash
go test ./examples/ch17/fuzz-reverse -v

再进行一秒变异探索:

bash
go test ./examples/ch17/fuzz-reverse -fuzz=FuzzReverse -fuzztime=1s

两条命令都应以 PASS 结束。还可以单独运行基准:

bash
go test ./examples/ch17/fuzz-reverse -run='^$' -bench=BenchmarkReverse -benchmem

拆解:

  • 种子覆盖空串、ASCII、中文和 emoji;普通 go test 会把它们作为回归用例执行。
  • Fuzzer 生成的字符串可以包含无效 UTF-8,所以性质只对有效输入断言“双重反转等于原值”。
  • 发现崩溃时,Go 会把输入写入 testdata/fuzz;应先修复再把该输入作为永久回归语料提交。
  • 基准的耗时和分配数字依赖机器,不应写死为测试阈值;它用于对比修改前后,而不是证明绝对性能。

修改实验:故意把 Reverse 改成按字节交换并运行 Fuzz,观察中文种子如何快速暴露错误;修复后用 -race 再验证一次。

22. 练习

  1. 为一个金额解析函数编写表驱动测试,覆盖空字符串、负数、小数位过多、最大值和非法字符。
  2. 为 HTTP client 使用 httptest.NewServer 模拟 200、429、500、慢响应和损坏 JSON。
  3. 给一个并发计数器写测试,并分别用互斥锁版和错误版运行 go test -race
  4. 为 URL 规范化函数写 round-trip 或幂等性 fuzz test。
  5. 为两种 JSON 编码方式写 B.Loop 基准,同时报告吞吐量和分配。
  6. 使用 -shuffle 找出一个依赖测试执行顺序的故意错误示例,再把共享状态改成测试内依赖。

23. 官方资料

以 Go 官方规范与标准库文档为准,示例面向 Go 1.26。