Go 的 testing 包 API 面小,上手成本低;单元测试质量的差异主要来自团队约定。缺乏约定的项目常见两类测试并存:一类行为稳定、接近契约;一类依赖执行顺序、间歇性失败(flaky)。CI 失败后以重跑代替排查,会掩盖真实缺陷。本文给出单元测试约定,覆盖文件组织、表驱动测试、helper 与资源清理、Example 测试、httptest、集成测试、mock 与覆盖率。断言库的 API 细节不在本文展开,统一参见 testify 实战。

文件组织

测试文件应与源文件一一对应:foo.go 对应 foo_test.go,不应按函数命名。go test、覆盖率报告、IDE 的"跳转到测试"以及 gotests 等脚手架,均依赖"源文件 → 测试文件"这条映射;按函数拆分文件会破坏该映射,检索测试只能依赖文本搜索。

helloworld.go → helloworld_test.go   // ✅ 一个源文件一个测试文件
helloworld.go → abcd_test.go         // ❌ 按函数命名,映射断裂

单个源文件过大、难以浏览时,才应拆出 foo_test.go + foo_edgecases_test.go;文件名仍从源文件名派生,不从函数名派生。

包名默认使用 mypackage_test 外部测试包,以黑盒方式测试。外部测试包只能访问导出 API,从而强制测试验证可观察行为;确需测试未导出逻辑时,再使用同包白盒测试:

package mypackage      // 同包:白盒,可访问未导出符号
package mypackage_test // 外部包:黑盒,只能使用导出 API

与之配套的规范是:断言应针对行为与公共 API 契约,避免耦合实现细节。耦合到内部实现的测试,每次重构都要随之重写,且无法证明任何契约。

测试函数在文件中的排列顺序应与源文件中被测函数保持一致,便于按位置对照阅读,减少检索成本。命名采用标准形态:TestAdd、TestMyStruct_MyMethod(方法)、BenchmarkXxx、ExampleXxx、FuzzXxx。

表驱动测试

表驱动测试要支持失败归因,关键是为每个用例命名:每个用例必须有 name 字段,并传给 t.Run。匿名循环会把所有失败压缩为一行,只能确定存在失败用例,无法确定失败的是哪一个。命名子测试使 go test -v 逐用例报告结果,并支持单独重放某个用例:go test -run 'TestCalculatePrice/zero_quantity'。

func TestCalculatePrice(t *testing.T) {
    tests := []struct {
        name      string // 子测试名,必须传给 t.Run
        quantity  int
        unitPrice float64
        want      float64
    }{
        {"single item", 1, 10.0, 10.0},
        {"bulk discount - 100 items", 100, 10.0, 900.0}, // 满 100 件九折
        {"zero quantity", 0, 10.0, 0.0},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := CalculatePrice(tt.quantity, tt.unitPrice); got != tt.want {
                t.Errorf("CalculatePrice(%d, %.2f) = %.2f, want %.2f",
                    tt.quantity, tt.unitPrice, got, tt.want)
            }
        })
    }
}

常见错误是断言实例的作用域跨越子测试:在父测试中 assert.New(t)、再传入子测试使用,失败会记录到父测试,子测试仍显示 --- PASS,真实失败因此被掩盖。规范是:断言实例的作用域不得跨越子测试,每个子测试内部基于自己的 t 重新构建:

// ❌ is 使用父测试的 t:失败归因错乱,子测试仍报 PASS
func TestX(t *testing.T) {
    is := assert.New(t)
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            is.Equal(tt.want, CalculatePrice(tt.quantity, tt.unitPrice))
        })
    }
}

// ✅ 每个子测试用自己的 t 构建断言
for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        is := assert.New(t)
        is.Equal(tt.want, CalculatePrice(tt.quantity, tt.unitPrice))
    })
}

自检方法:故意构造一个失败用例运行 go test -v,若父测试 FAIL 而所有子测试 PASS,说明断言实例的作用域发生了泄漏。此外,testify 是辅助工具而非必需品;断言数量少的测试,标准库 t.Errorf 即可满足。

任何测试都必须能够单独运行。依赖前序测试写入的数据或共享可变全局状态,会导致 go test -run <单个测试> 与并行调度失败;共享数据应由每个用例自建,或收敛进 helper。测试相互独立后,应添加 t.Parallel() 使子测试并发调度,该设置同时能暴露残留的共享状态问题。paralleltest、thelper 等 linter 会检查上述规则,配置见 lint 与静态检查。

基本要求:单元测试应快速(1ms 以内)、隔离、确定,不访问网络、磁盘与真实时钟;耗时较长的测试应归入集成测试。不满足该要求时,并行化无从实施。

helper 与资源清理

helper 函数第一行应调用 t.Helper(),资源清理应统一注册到 t.Cleanup。缺少 t.Helper() 时,失败堆栈指向 helper 内部的 t.Fatalf;调用之后,helper 从报错堆栈中隐去,行号直接指向真正的调用方。t.Cleanup 注册的函数在测试结束后(无论 PASS/FAIL)按注册逆序执行,资源关闭逻辑由 helper 自身负责,调用方无需成对的 defer:

// setupDB:准备数据库连接,清理逻辑内聚在 helper 里
func setupDB(t *testing.T) *sql.DB {
    t.Helper() // 失败时行号指向调用方,而不是这个 helper
    db, err := sql.Open("postgres", testDSN)
    if err != nil {
        t.Fatalf("open database: %v", err)
    }
    t.Cleanup(func() { db.Close() }) // 测试结束自动执行
    return db
}

可能阻塞的测试不应等待 go test 的全局 -timeout 作为最终防线,应在测试内部设置守护。常见模式:helper 启动守护 goroutine,测试正常结束时经 t.Cleanup 关闭信号通道;超时则 panic,并用 runtime.Callers 把调用方的文件与行号写入 panic 信息,从而直接定位挂起的测试:

// testWithTimeout:测试挂死时快速 panic 并定位调用方
func testWithTimeout(t *testing.T, timeout time.Duration) {
    t.Helper()
    done := make(chan struct{})
    t.Cleanup(func() { close(done) })
    go func() {
        select {
        case <-done:                // 测试正常结束
        case <-time.After(timeout): // panic 信息中附 runtime.Callers 位置
            panic("test timed out after " + timeout.String())
        }
    }()
}

包含后台 goroutine 的包,应在 TestMain 中挂载 goleak:goleak.VerifyTestMain(m) 会在测试包退出前扫描泄漏的 goroutine,将"goroutine 未退出"转换为一次明确的测试失败,而不是遗留为下次 CI 的随机阻塞;存在已知的库内部 goroutine 时,用 goleak.IgnoreCurrent() 排除。

Example 测试

公共 API 适合配示例时,应写成 Example 测试,而非 README 中的代码块。go test 会实际执行 Example 函数,并将 stdout 与 // Output: 注释逐行比对:示例与 API 不一致时,构建直接失败。文档由会过期的注释变为会报错的测试,pkg.go.dev 还会将其渲染在对应符号旁。

func ExampleCalculatePrice() {
    price := CalculatePrice(100, 10.0)
    fmt.Printf("Price: %.2f\n", price)
    // Output:
    // Price: 900.00
}

命名后缀决定示例在 godoc 中关联到哪个符号:Example() 对应整个包;ExampleCalculatePrice() 对应同名函数;ExampleStore_Get() 对应 Store 的 Get 方法;ExampleStore_Get_cached() 是同一方法的命名变体。变体后缀必须以小写字母开头——第二个下划线之后若以大写字母开头,会被解析为方法名,示例将无法关联到目标符号。

输出行序不确定时(map 遍历、并发生产者),应使用 // Unordered output:。没有 Output 注释的 Example 只编译不运行,适合依赖真实外部服务的场景,但不再验证任何行为——能写 Output 断言时不应省略。Example 应放在 _test.go 文件中,且优先放在 foo_test 外部包:仅用导出 API 即可运行的示例,才是公共接口可用的证明。

httptest:不监听端口

测试 HTTP handler 应使用 httptest.NewRequest 与 httptest.NewRecorder,直接调用 ServeHTTP,不监听端口。构造一个请求与一个响应记录器,handler 的状态码、响应头、响应体均可从 recorder 上断言,单次执行在毫秒级完成。该方式与表驱动测试配合:query 参数、Authorization 头都只是用例表的新字段:

func TestCreateUserHandler(t *testing.T) {
    tests := []struct {
        name, body string
        status     int
    }{
        {"valid request", `{"name": "Alice"}`, http.StatusCreated},
        {"invalid JSON", `not json`, http.StatusBadRequest},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(tt.body))
            w := httptest.NewRecorder()
            http.HandlerFunc(CreateUserHandler).ServeHTTP(w, req)
            assert.Equal(t, tt.status, w.Code)
        })
    }
}

仅当测试自身实现的 HTTP client 时,才需要 httptest.NewServer:它会启动一个真实监听的本地服务,并把 URL 提供给被测 client。Go 1.27 起还提供内存版 httptest.NewTestServer,可与 testing/synctest 组合,在没有真实 socket 与真实定时器的 synctest 气泡(bubble)内确定性地测试超时与重试逻辑(Go 1.25+ 的 synctest 也是治理时间类 flaky 测试的方案)。

集成测试

集成测试必须使用 build tag 与单元测试隔离。文件头声明 //go:build integration 后,默认的 go test ./... 不会编译执行这些文件;CI 或本机使用 go test -tags=integration ./... 触发。单元测试保持亚毫秒级,集成测试再慢也不拖累日常开发循环:

//go:build integration

package database_test

func TestOrderRepository(t *testing.T) {
    dsn := os.Getenv("TEST_DATABASE_URL") // 连接串来自环境变量,由 CI 提供
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()
    // ...真实数据库操作
}

外部依赖通过测试容器(docker compose fixture)供给,fixture 统一放在 testdata/,惯例包含三部分:testdata/docker-compose.yml(postgres/redis 等服务,必须配置 healthcheck)、testdata/schema.sql(建表)、testdata/testdata.sql(种子数据)。套件 setup 负责启动容器、等待健康检查通过、执行 schema;每个用例开始前 TRUNCATE 再写入种子数据,保证用例相互隔离;teardown 使用 down -v 连同数据卷一起清理。schema 与种子数据可用 go:embed 编入 helper,消除对工作目录的依赖。

需要注意的统计口径:不带 -tags=integration 运行得到的覆盖率不包含集成测试,汇总覆盖率时应补充该部分数据。

mock

只应 mock 接口;接口应定义在消费方,而不是实现方。这是 mock 可行性的前提:service 依赖自己声明的 Database 接口,测试才能注入 MockDatabase;依赖具体的 *sql.DB 则无法注入:

// user_service.go:接口由消费方定义,只包含它真正用到的方法
type Database interface {
    GetUser(id string) (*User, error)
}

反向检验:若为测试不得不为整个仓储层构造 mock,应先检查依赖边界的划分是否正确。

mock 的使用应保持克制。mock 本质是对依赖的一种断言——验证"以什么参数调用了什么"——而非默认配置;纯函数、内存 map、简单 fake 往往比 mock 更短更快。滥用 mock 会把测试锁定在实现细节上,与"断言行为而非实现"直接冲突。可复用的测试数据应收敛到 fixtures 包;时间相关的逻辑应注入 clockwork 的 FakeClock,避免 time.Sleep 等待。testify/mock 的 matchers、调用验证与 suite 用法,参见 testify 实战。

覆盖率

覆盖率回答"哪些行未执行",不回答"测试是否充分"。一行被执行不等于被断言——调用了函数却忽略返回值的测试,覆盖率 100%,验证度为 0%。覆盖率的正确用法是作为盲区探测器:生成 profile,打开 HTML 报告,检查未覆盖的行,并分析其未覆盖的原因:

go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out            # 未覆盖行标红
go tool cover -func=coverage.out | grep total

覆盖率数字失真,常见于以下统计口径问题:

  • 覆盖率默认按包统计:api 包的测试调用了 store 包,store 的数字不变;跨包统计需加 -coverpkg=./...。
  • 集成测试未带 tag 时,不进入统计。
  • 生成代码会推高数字,设置阈值前应先剔除。
  • 覆盖行 ≠ 断言行,不应以绿色百分比替代质量评估。

并发场景:与 -race 同跑时应使用 -covermode=atomic。默认 set 模式只记录是否执行过;count 记录执行次数,适合查找热点路径中从未进入的分支;atomic 在此之上保证并发安全,凡配合 t.Parallel() 或 -race 的场合均应使用——CI 上应启用 -race 运行测试。

基准与模糊测试

两者各有方法论(统计、profiling、CI 回归检测),展开见 基准测试规范,此处仅列要点:基准测试应使用 b.Run 拆分子基准——子基准名是 benchstat 这类比较工具做回归 diff 的锚点;Go 1.24+ 应使用 b.Loop() 代替 b.N 循环。模糊测试用 f.Add 提供种子,由引擎搜索未考虑到的边界;"反转两次等于自身"这类不变量是天然的模糊测试断言。

速查清单

  • 文件:foo.go 对应 foo_test.go,不按函数命名;拆分文件名也应从源文件名派生
  • 默认黑盒,写入 foo_test 包;断言行为与契约,不耦合实现细节
  • 测试函数顺序与源文件中的被测函数保持一致
  • 表驱动:每个用例必须有 name 并传给 t.Run;-run 'TestX/case' 单独重放
  • assert.New 必须在子测试内部基于各自的 t 构建
  • 测试相互独立,任一测试均可单独运行;独立后应添加 t.Parallel()
  • 单元测试:1ms 内、隔离、确定;耗时测试归入集成测试
  • helper 首行调用 t.Helper();清理统一注册 t.Cleanup,结束后逆序执行
  • 可能阻塞的测试应设置守护 goroutine,超时 panic 并附调用方位置
  • 含 goroutine 的包应挂载 goleak.VerifyTestMain
  • 公共 API 应写 Example;// Output: 使文档可执行;行序不定用 Unordered output;变体后缀小写开头
  • handler:httptest.NewRequest + NewRecorder 直调 ServeHTTP,不监听端口;测 client 才用 NewServer(Go 1.27+ 有内存版 NewTestServer 配 synctest)
  • 集成://go:build integration 隔离,-tags=integration 触发;testdata/ 下 compose + schema + 种子数据,用例间 TRUNCATE 重置
  • 只 mock 接口,接口定义在消费方;能 fake 不 mock;时间逻辑注入 clockwork,避免 sleep 等待
  • 覆盖率用于定位盲区,不作为 KPI;-race 用 atomic,跨包加 -coverpkg,剔除生成代码,统计应含集成 tag