Go 单元测试与基准测试实战:从 testing 包到表驱动与 mock 的完整实践

小小卒子2026-10-20后端⏱ 6min

Go 单元测试与基准测试实战:从 testing 包到表驱动与 mock 的完整实践

单元测试是 Go 工程文化里最深入人心的一环——官方工具链 go test 开箱即用,配合 testing 包能覆盖测试、基准、示例、覆盖率的全链路。本文从基础语法讲到表驱动测试、测试替身、并发安全测试与基准分析,帮你写出值得信赖的测试代码。

一、testing 包基础

Go 测试文件以 _test.go 结尾,与源码同目录。测试函数签名固定为 func TestXxx(t *testing.T):

// add.go
func Add(a, b int) int { return a + b }

// add_test.go
package main

import "testing"

func TestAdd(t *testing.T) {
    got := Add(1, 2)
    if got != 3 {
        t.Errorf("Add(1,2) = %d, want 3", got)
    }
}

常用断言方式:

  • t.Errorf:打印错误但继续执行(适合一轮测多个断言);
  • t.Fatalf:立即终止当前测试;
  • t.Logf:打印日志(只有失败或加 -v 时显示);
  • t.Helper():标记辅助函数,报错时定位到调用方而不是辅助函数内部。

运行方式:

go test ./...            # 全包测试
go test -v -run TestAdd  # 只跑指定测试
go test -cover           # 覆盖率

二、表驱动测试:Go 社区的黄金标准

与其一个用例写一个函数,不如用"表"组织输入输出,一个测试函数跑完所有用例:

func TestAddTable(t *testing.T) {
    cases := []struct {
        name string
        a, b int
        want int
    }{
        {"正数", 1, 2, 3},
        {"负数", -1, -2, -3},
        {"零值", 0, 0, 0},
        {"溢出边界", int(^uint(0)>>1), 1, int(^uint(0)>>1) + 1},
    }
    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            if got := Add(c.a, c.b); got != c.want {
                t.Errorf("Add(%d,%d) = %d, want %d", c.a, c.b, got, c.want)
            }
        })
    }
}

t.Run 创建子测试,好处:

  1. 失败时能精确定位是哪个用例(--- FAIL: TestAddTable/正数);
  2. 可以 go test -run 'TestAddTable/负数' 单独跑某个用例;
  3. 支持并行子测试(t.Parallel())。

表驱动 + 子测试是 Go 官方认可的测试范式,几乎适用于所有纯函数和逻辑分支测试。

三、测试替身:mock 与 fake

被测代码依赖外部资源(数据库、HTTP 服务、文件系统)时,有两种策略:

1. 接口注入 + fake 实现(推荐)

把依赖抽象成接口,测试时注入轻量实现:

type UserRepo interface {
    Get(id int) (*User, error)
}

type service struct {
    repo UserRepo
}

// 测试里的 fake
type fakeRepo struct{}

func (fakeRepo) Get(id int) (*User, error) {
    if id == 1 {
        return &User{ID: 1, Name: "小卒子"}, nil
    }
    return nil, errors.New("not found")
}

func TestGetUser(t *testing.T) {
    svc := &service{repo: fakeRepo{}}
    u, err := svc.Get(1)
    if err != nil || u.Name != "小卒子" {
        t.Fatalf("unexpected: %v, %v", u, err)
    }
}

这就是 Go 社区推崇"面向接口编程"的原因——依赖注入让测试不用引入任何 mock 库。

2. 网络依赖用 httptest

httptest.NewServer 起一个真实 HTTP 服务器,测试 HTTP 客户端逻辑:

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()

resp, _ := http.Get(srv.URL)

标准库 net/http/httptest 还提供 httptest.NewRecorder 测试 handler,不用起端口就能验证路由响应。

四、并发代码的测试

goroutine 和 channel 的错误大多发生在并发场景,用竞态检测器抓:

go test -race ./...

-race 基于 ThreadSanitizer,能检测 data race,生产项目 CI 里必须开启。测试并发逻辑时还要控制确定性:

func TestWorker(t *testing.T) {
    var wg sync.WaitGroup
    done := make(chan struct{})
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // 工作
        }()
    }
    go func() { wg.Wait(); close(done) }()

    select {
    case <-done:
    case <-time.After(2 * time.Second):
        t.Fatal("worker 超时,可能死锁")
    }
}

给并发测试加超时保护,避免死锁时测试挂死整个 CI。

五、基准测试:Benchmark 的正确姿势

基准函数签名 func BenchmarkXxx(b *testing.B),框架自动决定迭代次数:

func BenchmarkBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for j := 0; j < 100; j++ {
            sb.WriteString("x")
        }
    }
}

运行 go test -bench=. -benchmem,输出:

BenchmarkBuilder-8    385240    3124 ns/op    0 B/op    0 allocs/op

解读字段:385240 迭代次数、3124 ns/op 单次耗时、0 B/op 每次分配字节、0 allocs/op 每次分配次数。allocs/op 是 Go 性能优化最重要的指标——减少分配通常比减少耗时更有效,因为 GC 压力是全局的。

对比两个实现时用 -benchtime=3s 增加采样时间,且两个 benchmark 要在同一台机器、同一状态下跑,才能比较。还想稳定比较可以加 -count=5 取多次均值。

b.ResetTimer() 可以在 setup 之后才开始计时;b.ReportAllocs() 或 -benchmem 输出分配信息。

六、覆盖率与 Example 测试

覆盖率分析

go test -coverprofile=cover.out ./...
go tool cover -func=cover.out   # 按函数看覆盖率
go tool cover -html=cover.out   # 浏览器可视化

覆盖率是手段不是目标——分支覆盖率比行覆盖率更有意义。对关键逻辑(金额计算、权限判断、协议解析)要求覆盖所有 if/else 分支,而不是只追求数字好看。

Example 测试:可执行的文档

func ExampleAdd() {
    fmt.Println(Add(1, 2))
    // Output: 3
}

Example 测试同时是文档示例和断言测试,// Output: 注释会被比对输出。它会被 godoc 渲染到文档里,是"文档即测试"的典范。

七、实践清单

  • 表驱动 + 子测试组织所有用例,命名用业务语义("正数""空输入")而非序号;
  • 依赖一律接口注入,测试用 fake 而非重连真实服务;
  • CI 里 go test -race -cover ./... 是底线;
  • 被测代码把边界条件(零值、nil、超长、溢出)都写进表;
  • 性能优化前后用 benchmem 对比 allocs/op,用数据说话;
  • 错误处理测试覆盖 %w 包装链路,用 errors.Is/As 断言根因。

测试的价值不在"写了多少行",而在替你挡下了多少线上事故。Go 把测试工具内置到工具链、文档、覆盖率全链路,这套机制配合"测试优先"的工程文化,正是 Go 在云原生领域被广泛采用的重要原因之一。

读完了?给作者一点支持

本文采用 CC BY 4.0 协议转载 · © 小卒子

评论区加载中…

🔗 相关文章