Go 测试与基准测试

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

Go 1.24

Go 把测试做进了工具链:go test 就是全部,不需要额外的断言库或测试框架。这一篇讲怎么写、怎么跑、怎么定位慢的地方。

环境准备

示例在 Go 1.24 下验证,只用标准库。

第一个测试

// calc/calc.go
package calc

import "errors"

var ErrDivideByZero = errors.New("除以零")

func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, ErrDivideByZero
    }
    return a / b, nil
}
// calc/calc_test.go
package calc

import (
    "errors"
    "testing"
)

func TestDivide(t *testing.T) {
    got, err := Divide(10, 2)
    if err != nil {
        t.Fatalf("不期望错误:%v", err)
    }
    if got != 5 {
        t.Errorf("Divide(10, 2) = %v,期望 5", got)
    }
}

规则:

  • 文件名以 _test.go 结尾,与被测代码同包(package calc)或外部测试包(package calc_test)。
  • 函数名 TestXxx(t *testing.T),Xxx 首字母大写。
  • t.Errorf 记录失败并继续;t.Fatalf 立即终止当前测试函数。
  • 测试文件不参与正常构建,也不会进二进制。

表驱动测试

多个用例写成切片,新增用例只加一行:

func TestDivide_Table(t *testing.T) {
    tests := []struct {
        name    string
        a, b    float64
        want    float64
        wantErr error
    }{
        {name: "正常", a: 10, b: 2, want: 5},
        {name: "小数", a: 1, b: 4, want: 0.25},
        {name: "负数", a: -8, b: 2, want: -4},
        {name: "除以零", a: 1, b: 0, wantErr: ErrDivideByZero},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := Divide(tt.a, tt.b)

            if tt.wantErr != nil {
                if !errors.Is(err, tt.wantErr) {
                    t.Fatalf("错误 = %v,期望 %v", err, tt.wantErr)
                }
                return
            }
            if err != nil {
                t.Fatalf("不期望错误:%v", err)
            }
            if got != tt.want {
                t.Errorf("结果 = %v,期望 %v", got, tt.want)
            }
        })
    }
}

t.Run 生成子测试,名字会出现在输出里(TestDivide_Table/除以零),可以单独跑:

go test ./... -run 'TestDivide_Table/除以零'

注意:循环里的 tt 在 Go 1.22 起每次迭代都是新变量,可以放心直接用(旧版本才需要 tt := tt)。

辅助函数与清理

func newTestDB(t *testing.T) *sql.DB {
    t.Helper()                                  // 失败时报调用方行号

    db, err := sql.Open("sqlite", "file::memory:?cache=shared")
    if err != nil {
        t.Fatalf("打开数据库:%v", err)
    }

    t.Cleanup(func() { db.Close() })            // 测试结束自动执行,含子测试
    return db
}

t.TempDir() 创建测试结束自动删除的目录,适合文件相关逻辑:

func TestWriteFile(t *testing.T) {
    dir := t.TempDir()
    path := filepath.Join(dir, "out.txt")

    if err := os.WriteFile(path, []byte("hi"), 0o644); err != nil {
        t.Fatal(err)
    }

    data, err := os.ReadFile(path)
    if err != nil || string(data) != "hi" {
        t.Fatalf("读取结果 = %q, %v", data, err)
    }
}

并行测试:t.Parallel() 放在测试函数开头,注意共享变量与全局状态。

测试 HTTP handler

func TestHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/hello?name=go", nil)
    rec := httptest.NewRecorder()

    hello(rec, req)

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

    if res.StatusCode != http.StatusOK {
        t.Fatalf("状态码 = %d", res.StatusCode)
    }
    body, _ := io.ReadAll(res.Body)
    if !strings.Contains(string(body), "go") {
        t.Errorf("响应体 = %s", body)
    }
}

httptest 不用真的监听端口,速度快且稳定。

基准测试

func BenchmarkConcat(b *testing.B) {
    parts := []string{"a", "b", "c", "d"}

    for b.Loop() {               // Go 1.24 起的写法
        _ = strings.Join(parts, ",")
    }
}

旧写法是 for i := 0; i < b.N; i++,仍然可用。运行:

go test -bench=. -benchmem ./...

输出:

BenchmarkConcat-8   12345678   95.2 ns/op   32 B/op   1 allocs/op

三个数字关键:每次操作耗时、每次分配字节数、分配次数。减少 allocs/op 往往比压耗时更有效。

对比两个实现时用 benchstat(golang.org/x/perf/cmd/benchstat):

go test -bench=. -count=10 > old.txt
# 改动代码
go test -bench=. -count=10 > new.txt
benchstat old.txt new.txt

单次跑的差异常常是噪声,多次采样再看统计量。

覆盖率

go test ./... -cover
go test ./... -coverprofile=cover.out
go tool cover -html=cover.out

覆盖率衡量「被执行的行」,不衡量断言质量。80% 不是目标,未覆盖的分支才是要看的:错误处理、边界值、空输入。

Fuzz 测试

func FuzzReverse(f *testing.F) {
    f.Add("你好")
    f.Add("abc")

    f.Fuzz(func(t *testing.T, s string) {
        got := Reverse(Reverse(s))
        if got != s {
            t.Fatalf("双重反转 = %q,原值 %q", got, s)
        }
    })
}
go test -fuzz=FuzzReverse -fuzztime=30s

Fuzz 会随机生成输入并保存导致失败的用例到 testdata/fuzz/,之后 go test 会自动回归。解析器、序列化、字符串处理特别值得加。

示例测试

func ExampleDivide() {
    q, _ := Divide(10, 4)
    fmt.Println(q)
    // Output: 2.5
}

// Output: 注释让 go test 比对输出,示例同时成为文档。

测试组织习惯

  • 测试放在被测包旁边,不建单独的 tests/ 目录。
  • 需要访问未导出标识符就用同包测试;只测公开 API 用 _test 包。
  • 一个测试只验证一个行为;表驱动的每个用例都要有可读的 name。
  • 用 go test ./... -race 检查数据竞争,CI 里必跑。
  • 慢测试用 testing.Short() 分级:
func TestIntegration(t *testing.T) {
    if testing.Short() {
        t.Skip("跳过集成测试")
    }
    // ...
}
go test -short ./...

常见错误与注意事项

1. 用 t.Log 当断言

日志不会让测试失败。判断必须落到 t.Error / t.Fatal。

2. t.Fatalf 写在 goroutine 里

Fatalf 只能在测试主 goroutine 调用。子 goroutine 里用 t.Errorf 并通过 channel 汇报。

3. 忘记 defer res.Body.Close()

连接不释放,多个测试后可能耗尽。

4. 测试依赖执行顺序

Go 不保证顺序(开了 -shuffle 更明显)。每个测试自己准备状态。

5. 用真实网络与真实时间

外部服务会让 CI 不稳定。用 httptest 与接口注入;时间用可注入的 now func() time.Time。

6. 基准测试里包含测量外的开销

初始化放在循环之前,b.ResetTimer() 用于排除准备时间。

7. 只跑 go test 不看 -race

并发 bug 在普通测试里常常不显形。CI 加 -race。

小结

  • testing 标准库够用:表驱动 + t.Run 是默认写法。
  • t.Helper 改行号、t.Cleanup/t.TempDir 管资源。
  • 基准测试看 ns/op、B/op、allocs/op,多次采样再比较。
  • 覆盖率找漏测分支,fuzz 找边界崩溃,示例测试兼做文档。
  • CI 至少跑 go test ./... -race。

下一步