Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第27章 测试与工具链(重点)

测试不是“给函数补几个 case”,而是把行为、并发、兼容性和性能变成可重复检查的工程契约。本章基于 Go 1.26,并标出近几个版本新增的测试能力。

27.1 表驱动测试

表驱动测试让输入、期望和失败信息集中表达:

func TestNormalize(t *testing.T) {
    tests := []struct {
        name string
        in   string
        want string
    }{
        {name: "trim", in: "  Go  ", want: "go"},
        {name: "empty", in: "", want: ""},
        {name: "unicode", in: "  世界 ", want: "世界"},
    }

    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            got := Normalize(test.in)
            if got != test.want {
                t.Fatalf("Normalize(%q) = %q, want %q", test.in, got, test.want)
            }
        })
    }
}

断言信息应包含操作、输入、实际值和期望值。不要只输出“failed”。优先比较公开行为,避免测试私有实现步骤导致无意义的重构阻力。

Go 1.22 起 range 变量每次迭代独立,子测试闭包不再需要 test := test 兼容写法;维护旧 go 语言版本模块时仍要留意其语义。

27.2 子测试与并行

t.Parallel 让独立 case 并发执行:

for _, test := range tests {
    t.Run(test.name, func(t *testing.T) {
        t.Parallel()
        // 不访问共享的可变全局状态。
    })
}

并行测试不能共享临时端口、进程级环境变量、当前工作目录或未同步的 fixture。t.Setenvt.Chdir 与并行测试有明确限制,测试框架会对部分误用直接 panic。

按层次组织子测试,便于只运行失败场景:

go test ./internal/store -run '^TestStore/Postgres/Conflict$'

27.3 Cleanup、TempDir 与 Context

资源清理使用 t.Cleanup,它在测试及其子测试结束后按后进先出执行:

func startTestServer(t *testing.T) *Server {
    t.Helper()
    server := NewServer()
    if err := server.Start(); err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { _ = server.Close() })
    return server
}

常用隔离能力:

  • t.TempDir():自动清理的临时目录。
  • t.Setenv():测试结束自动恢复环境变量。
  • t.Context():Go 1.24 起提供,在 Cleanup 开始前取消。
  • t.Chdir():Go 1.24 起临时切换工作目录。

helper 调用 t.Helper(),失败位置才会指向调用者。

27.4 依赖替换与测试边界

小 interface 应定义在消费方:

type UserStore interface {
    Load(context.Context, string) (User, error)
}

type stubStore struct {
    load func(context.Context, string) (User, error)
}

func (s stubStore) Load(ctx context.Context, id string) (User, error) {
    return s.load(ctx, id)
}

不要为了 mock 把所有结构都接口化。纯函数直接测;文件系统可用 fstest.MapFS;HTTP handler 用 httptest;时间和并发优先用显式依赖或 testing/synctest

数据库、消息队列等协议边界应保留少量真实集成测试。内存 fake 很难复现事务隔离、序列化、超时和服务端错误。

27.5 Fuzzing

Go 1.18 把 fuzzing 集成进 testing。Fuzz test 先运行 seed corpus,再由引擎变异输入:

func FuzzRoundTrip(f *testing.F) {
    f.Add([]byte("hello"))
    f.Add([]byte{})

    f.Fuzz(func(t *testing.T, input []byte) {
        encoded := Encode(input)
        decoded, err := Decode(encoded)
        if err != nil {
            t.Fatalf("Decode(Encode(input)): %v", err)
        }
        if !bytes.Equal(decoded, input) {
            t.Fatalf("round trip mismatch")
        }
    })
}

运行方式:

go test -fuzz=FuzzRoundTrip -fuzztime=30s ./codec

好的性质包括 round-trip、幂等、解析后再编码、不同实现等价和“永不 panic”。Fuzz 发现的最小输入会写入 testdata/fuzz,应提交为回归 corpus。

Fuzz 目标必须确定、资源有界。限制输入大小、递归深度和执行时间,避免把 OOM 当成有效发现。

27.6 Benchmark 与 B.Loop

Go 1.24 的 B.Loop 是当前推荐写法:

func BenchmarkEncode(b *testing.B) {
    input := newFixture()
    for b.Loop() {
        benchmarkSink = Encode(input)
    }
}

B.Loop 自动控制计时区间,并帮助避免编译器消除循环体。旧版本仍使用 for i := 0; i < b.N; i++

记录分配并多次采样:

go test -run='^$' -bench=BenchmarkEncode -benchmem -count=10 > old.txt
# 修改代码
go test -run='^$' -bench=BenchmarkEncode -benchmem -count=10 > new.txt
benchstat old.txt new.txt

benchmark 报告必须附 CPU、GOOS/GOARCH、Go 版本、输入规模和统计方法。单次结果或不同机器之间的纳秒差不能支持结论。

27.7 并发测试与 synctest

Go 1.25 的 testing/synctest 在隔离的并发环境中运行函数,虚拟化时间,并能等待其他 goroutine 阻塞:

func TestTimeout(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        started := time.Now()
        <-time.After(time.Hour)
        if elapsed := time.Since(started); elapsed != time.Hour {
            t.Fatalf("elapsed = %v", elapsed)
        }
    })
}

这样测试一小时超时无需真实等待。synctest.Wait() 可等待环境内其他 goroutine 到达稳定阻塞状态,适合测试取消和后台循环。

它不能替代生产同步。被测代码仍必须建立正确的 happens-before 关系,并继续通过:

go test -race ./...

27.8 Golden、Example 与快照

  • ExampleXxx 同时是文档和可执行测试,// Output: 决定期望输出。
  • Golden 文件放在 testdata/,只对稳定、可审阅的文本或二进制格式使用。
  • 更新 golden 必须通过显式 flag,不能在普通测试运行时静默覆盖期望。
  • 对 JSON 等结构化数据先解析再比较,避免字段顺序和空白造成脆弱测试。

快照不应替代语义断言。巨大 diff 无法说明真正破坏了什么。

27.9 Modules 与 MVS

go.mod 至少记录:

module example.com/project

go 1.26

toolchain go1.26.4
  • go 行选择语言版本,并影响标准库行为的兼容默认值。
  • toolchain 建议使用的工具链;它不是依赖锁文件。
  • Minimal Version Selection 为每个 module path 选择依赖图中要求的最高最低版本。
  • go.sum 校验下载内容,不保证整个构建环境完全可复现。

常用检查:

go mod tidy
go mod verify
go list -m -u all
go mod why -m example.com/dependency

replace 适合本地开发和受控迁移,不应长期指向开发者机器路径。

27.10 Workspaces 与私有模块

go work 用于同时开发多个 module,不需要向每个 go.mod 写临时 replace:

go work init ./service ./library
go work use ./tools

通常不把个人工作区文件提交到单 module 仓库;monorepo 可根据团队约定提交。

私有模块使用 GOPRIVATE 声明路径模式,避免向公共 proxy 和 checksum database 泄露名称:

go env -w GOPRIVATE=git.example.com/*

凭据交给 Git credential helper、SSH agent 或 CI secret,不写进 import path、go.mod 和镜像层。

27.11 Build Tags、embed 与 generate

Build tag 必须位于文件顶部:

//go:build linux && amd64

package platform

平台文件应提供相同 API,并至少在 CI 做交叉编译。tag 组合过多会产生未测试分支。

//go:embed 把构建时文件嵌入二进制,适合模板和静态资源,不适合秘密与运行时配置:

//go:embed migrations/*.sql
var migrations embed.FS

go generate 只在显式执行时运行,不是 go build 的一部分。生成文件应带生成声明和版本固定方式,CI 验证重新生成后工作区无 diff。

27.12 PGO

Go 1.21 正式支持 Profile-Guided Optimization。把代表性 CPU profile 命名为包主模块根目录的 default.pgogo build 会自动使用;也可显式指定:

go build -pgo=profiles/production.pprof ./cmd/server

Profile 必须来自代表性、可信的工作负载。验证流程是:

  1. 保存无 PGO 基线。
  2. 使用生产或逼真压测采集 CPU profile。
  3. 分别构建并做统计 benchmark、负载测试和二进制体积比较。
  4. 在升级 Go 版本或热点变化后重新采集。

不要承诺固定百分比收益;PGO 对没有覆盖到的热点帮助有限,也可能增加构建时间和代码体积。

27.13 推荐 CI 分层

快速门禁:

gofmt -d .
go vet ./...
go test ./...
go test -race ./...
go test -gcflags=all=-d=checkptr=2 ./...

定期任务再运行 fuzz、跨平台构建、集成测试、benchmark 趋势和漏洞扫描。不要让一个数十分钟且易抖动的任务阻塞每次小提交;按风险分层并保留明确的发布门禁。

本章小结

  • 表驱动测试、子测试和 Cleanup 构成可维护单元测试的基础。
  • Fuzz 检查性质,B.Loop 测量性能,synctest 控制并发时间,race detector 检查实际执行路径。
  • go.mod 的 gotoolchain 含义不同,MVS、go work 和私有模块配置决定依赖可重复性。
  • build tag、embed、generate 和 PGO 都必须进入 CI,不能依赖开发者记忆。

进一步阅读: