Go 测试进阶:从单元测试到基准测试

全面掌握 Go 测试进阶技巧:表驱动测试、子测试、测试辅助、Mock 模式、集成测试、基准测试、模糊测试、覆盖率分析与 CI/CD 集成

Go 测试进阶:从单元测试到基准测试

“我的代码能跑就行,测试是浪费时间。”

这句话我听过无数次。每次听到,我都想把这个人的手机拿过来,翻翻他的 Git 提交历史——我敢打赌,里面一定有不少 fix bugfix againreally fix this time 这样的提交信息。

不写测试的代码,就像没有安全带的赛车——跑得快的前提是你不出事。一旦出事,就是车毁人亡。

Go 语言把测试放在了极其重要的位置。testing 是标准库的一部分,go test 是工具链内置的命令,连测试覆盖率工具都是现成的。你不需要装任何第三方框架就能写出一流的测试。

在第 18 篇文章里,我们学习了测试的基础。今天,我们要进入"进阶区"——表驱动测试、子测试、测试辅助、Mock 模式、集成测试、基准测试、模糊测试、覆盖率分析、CI/CD 集成……把 Go 的测试武器库全部装满。

表驱动测试:Go 社区的黄金模式

如果只学一种测试模式,那就学表驱动测试。这是 Go 社区最推崇的测试模式,几乎所有标准库的测试都在用它。

核心思想很简单:把多个测试用例组织成一张"表",用循环遍历执行。

基础表驱动

来看一个例子。假设你写了一个计算器:

package calc

import "errors"

func Add(a, b int) int         { return a + b }
func Subtract(a, b int) int    { return a - b }
func Multiply(a, b int) int    { return a * b }

func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

新手可能会这样写测试:

// ❌ 冗长、重复、难维护
func TestAdd1(t *testing.T) {
    if Add(1, 2) != 3 {
        t.Error("1+2 should be 3")
    }
}

func TestAdd2(t *testing.T) {
    if Add(-1, 1) != 0 {
        t.Error("-1+1 should be 0")
    }
}

func TestAdd3(t *testing.T) {
    if Add(0, 0) != 0 {
        t.Error("0+0 should be 0")
    }
}
// ... 还有几十个类似的函数

表驱动测试这样写:

// ✅ 简洁、清晰、易扩展
func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"两个正数", 1, 2, 3},
        {"一正一负", -1, 1, 0},
        {"两个零", 0, 0, 0},
        {"两个负数", -3, -5, -8},
        {"大数相加", 999999, 1, 1000000},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; want %d",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

子测试:t.Run 的威力

t.Run 创建的每个子测试都是独立的。好处非常明显:

# 只运行某个子测试
$ go test -v -run "TestAdd/两个正数"
=== RUN   TestAdd/两个正数
--- PASS: TestAdd/两个正数 (0.00s)
PASS

# 查看所有子测试
$ go test -v -run "TestAdd"
=== RUN   TestAdd
=== RUN   TestAdd/两个正数
=== RUN   TestAdd/一正一负
=== RUN   TestAdd/两个零
=== RUN   TestAdd/两个负数
=== RUN   TestAdd/大数相加
--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/两个正数 (0.00s)
    --- PASS: TestAdd/一正一负 (0.00s)
    ...

一个子测试失败,不影响其他子测试继续执行。这在调试时非常有用——你可以一次看到所有失败的情况,而不是修一个跑一个。

测试带错误的函数

表驱动同样适合测试错误路径:

func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        expected  float64
        expectErr bool
    }{
        {"正常除法", 10, 2, 5.0, false},
        {"除以小数", 10, 0.5, 20.0, false},
        {"负数除法", -10, 2, -5.0, false},
        {"除以零", 10, 0, 0, true},
        {"零除以数", 0, 5, 0.0, false},
    }

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

            if tt.expectErr {
                if err == nil {
                    t.Errorf("Divide(%v, %v) 期望错误但没有", tt.a, tt.b)
                }
                return
            }

            if err != nil {
                t.Fatalf("Divide(%v, %v) 意外错误: %v", tt.a, tt.b, err)
            }

            if result != tt.expected {
                t.Errorf("Divide(%v, %v) = %v; want %v",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

并行子测试

对于互相独立的测试用例,可以用 t.Parallel() 并行执行:

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        // ... 大量测试用例
    }

    for _, tt := range tests {
        tt := tt // Go 1.22 之前需要这一行
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // 标记为可并行执行
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; want %d",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

⚠️ 注意 tt := tt 这一行。在 Go 1.22 之前,for range 的循环变量在每次迭代中是同一个变量,闭包捕获的是它的引用。不加这行,所有并行子测试可能用的是最后一次迭代的值。Go 1.22 修复了这个问题。

测试辅助函数:t.Helper() 的妙用

当多个测试有重复的设置逻辑时,提取辅助函数是自然的选择。但这里有个坑:

// ❌ 错误报告指向辅助函数内部,不好定位
func createUser(t *testing.T, name string) *User {
    user, err := NewUser(name)
    if err != nil {
        t.Fatalf("创建用户失败: %v", err)
        // 报错指向这一行,但你不知道是哪个测试调用的
    }
    return user
}

t.Helper() 解决了这个问题:

// ✅ 错误报告指向调用辅助函数的地方
func createUser(t *testing.T, name string) *User {
    t.Helper() // 标记为辅助函数

    user, err := NewUser(name)
    if err != nil {
        t.Fatalf("创建用户失败: %v", err)
        // 现在报错指向调用 createUser 的那一行
    }
    return user
}

func TestUserProfile(t *testing.T) {
    user := createUser(t, "张三") // 如果有错,报错指向这一行
    // ...
}

func TestUserDeletion(t *testing.T) {
    user := createUser(t, "李四") // 如果有错,报错指向这一行
    // ...
}

t.Cleanup:自动资源清理

测试中创建的资源(数据库连接、临时文件等)需要在测试结束后清理。t.Cleanup 让这个变得优雅:

func setupTestDB(t *testing.T) *DB {
    t.Helper()

    db, err := NewDB(":memory:")
    if err != nil {
        t.Fatalf("创建测试数据库失败: %v", err)
    }

    // 注册清理函数,测试结束时自动调用
    t.Cleanup(func() {
        db.Close()
    })

    // 初始化测试数据
    db.Exec("INSERT INTO users (name, age) VALUES ('Alice', 25)")
    db.Exec("INSERT INTO users (name, age) VALUES ('Bob', 30)")

    return db
}

func TestGetUser(t *testing.T) {
    db := setupTestDB(t)
    // 直接用 db,不用操心清理

    user, err := db.GetUser("Alice")
    if err != nil {
        t.Fatal(err)
    }
    if user.Age != 25 {
        t.Errorf("age = %d; want 25", user.Age)
    }
}

t.Cleanupdefer 更好用,因为它可以注册多个清理函数,而且在子测试中注册的清理函数只会在子测试结束时执行,不会影响父测试。

测试 Fixtures:组织测试数据

复杂的测试往往需要大量的测试数据。把这些数据直接写在测试函数里会让代码变得臃肿。

从文件加载 Fixture

package myapp

import (
    "encoding/json"
    "os"
    "path/filepath"
    "runtime"
    "testing"
)

// loadFixture 从 testdata 目录加载测试数据
func loadFixture(t *testing.T, name string, v any) {
    t.Helper()

    // runtime.Caller 获取当前文件路径,从而定位 testdata 目录
    _, filename, _, _ := runtime.Caller(0)
    dir := filepath.Dir(filename)
    path := filepath.Join(dir, "testdata", name)

    data, err := os.ReadFile(path)
    if err != nil {
        t.Fatalf("加载 fixture %s 失败: %v", name, err)
    }

    if err := json.Unmarshal(data, v); err != nil {
        t.Fatalf("解析 fixture %s 失败: %v", name, err)
    }
}

func TestProcessOrder(t *testing.T) {
    var order Order
    loadFixture(t, "order_valid.json", &order)

    result, err := ProcessOrder(order)
    if err != nil {
        t.Fatal(err)
    }

    if result.Status != "completed" {
        t.Errorf("status = %s; want completed", result.Status)
    }
}

testdata 目录有个特殊属性:Go 工具链会忽略它,不会编译里面的文件。这是专门用来放测试数据的约定目录。

myapp/
├── myapp.go
├── myapp_test.go
└── testdata/
    ├── order_valid.json
    ├── order_invalid.json
    └── order_empty.json

黄金文件(Golden Files)测试

对于复杂的输出(比如 JSON 响应、HTML 页面),手写期望值太麻烦。黄金文件测试的思路是:第一次运行时生成"黄金文件",后续运行时与黄金文件比较。

package myapp

import (
    "flag"
    "os"
    "path/filepath"
    "runtime"
    "testing"
)

// -update 标志:更新黄金文件
var update = flag.Bool("update", false, "更新黄金文件")

func getTestDataDir(t *testing.T) string {
    _, filename, _, _ := runtime.Caller(0)
    return filepath.Join(filepath.Dir(filename), "testdata")
}

func TestGenerateReport(t *testing.T) {
    report := GenerateReport([]Record{
        {Name: "Alice", Score: 95},
        {Name: "Bob", Score: 87},
    })

    goldenFile := filepath.Join(getTestDataDir(t), "report.golden")

    if *update {
        // 更新黄金文件
        os.WriteFile(goldenFile, []byte(report), 0644)
    }

    // 读取黄金文件
    expected, err := os.ReadFile(goldenFile)
    if err != nil {
        t.Fatalf("读取黄金文件失败: %v", err)
    }

    if report != string(expected) {
        t.Errorf("报告内容与黄金文件不匹配:\nGot:\n%s\n\nWant:\n%s",
            report, string(expected))
    }
}

使用方式:

# 第一次运行:生成黄金文件
go test -update

# 后续运行:与黄金文件比较
go test

Mock 模式:隔离依赖的艺术

单元测试的核心原则是隔离——你只测你写的代码,不测数据库、不测外部 API、不测文件系统。但现实中,代码总有依赖。怎么办?Mock 它。

基于接口的 Mock

Go 的接口是天然的 Mock 入口。只要依赖是通过接口注入的,就可以用假实现替换真实现。

package user

import "context"

// UserRepository 定义数据访问接口
type UserRepository interface {
    FindByID(ctx context.Context, id int) (*User, error)
    Save(ctx context.Context, user *User) error
    Delete(ctx context.Context, id int) error
}

// EmailSender 定义邮件发送接口
type EmailSender interface {
    Send(ctx context.Context, to string, subject, body string) error
}

// UserService 依赖接口而不是具体实现
type UserService struct {
    repo  UserRepository
    email EmailSender
}

func NewUserService(repo UserRepository, email EmailSender) *UserService {
    return &UserService{repo: repo, email: email}
}

func (s *UserService) Register(ctx context.Context, name, email string) (*User, error) {
    user := &User{
        Name:  name,
        Email: email,
    }

    if err := s.repo.Save(ctx, user); err != nil {
        return nil, err
    }

    if err := s.email.Send(ctx, email, "欢迎", "欢迎加入!"); err != nil {
        // 邮件发送失败不回滚用户(实际业务可能需要)
        return user, nil
    }

    return user, nil
}

手动 Mock

package user_test

import (
    "context"
    "errors"
    "testing"
)

// MockUserRepository 假的用户仓库
type MockUserRepository struct {
    users    map[int]*User
    saveErr  error
    nextID   int
}

func NewMockUserRepository() *MockUserRepository {
    return &MockUserRepository{
        users:  make(map[int]*User),
        nextID: 1,
    }
}

func (m *MockUserRepository) FindByID(ctx context.Context, id int) (*User, error) {
    user, ok := m.users[id]
    if !ok {
        return nil, errors.New("user not found")
    }
    return user, nil
}

func (m *MockUserRepository) Save(ctx context.Context, user *User) error {
    if m.saveErr != nil {
        return m.saveErr
    }
    user.ID = m.nextID
    m.nextID++
    m.users[user.ID] = user
    return nil
}

func (m *MockUserRepository) Delete(ctx context.Context, id int) error {
    delete(m.users, id)
    return nil
}

// MockEmailSender 假的邮件发送器
type MockEmailSender struct {
    sentEmails []SentEmail
    sendErr    error
}

type SentEmail struct {
    To      string
    Subject string
    Body    string
}

func (m *MockEmailSender) Send(ctx context.Context, to string, subject, body string) error {
    if m.sendErr != nil {
        return m.sendErr
    }
    m.sentEmails = append(m.sentEmails, SentEmail{
        To:      to,
        Subject: subject,
        Body:    body,
    })
    return nil
}

使用 Mock 写测试

func TestUserService_Register(t *testing.T) {
    t.Run("正常注册", func(t *testing.T) {
        repo := NewMockUserRepository()
        email := &MockEmailSender{}
        service := NewUserService(repo, email)

        user, err := service.Register(context.Background(), "张三", "zhang@example.com")
        if err != nil {
            t.Fatalf("注册失败: %v", err)
        }

        // 验证用户被保存
        if user.ID == 0 {
            t.Error("用户 ID 未分配")
        }
        if user.Name != "张三" {
            t.Errorf("name = %s; want 张三", user.Name)
        }

        // 验证邮件被发送
        if len(email.sentEmails) != 1 {
            t.Fatalf("期望发送 1 封邮件,实际发送了 %d 封", len(email.sentEmails))
        }
        if email.sentEmails[0].To != "zhang@example.com" {
            t.Errorf("邮件接收者 = %s; want zhang@example.com", email.sentEmails[0].To)
        }
    })

    t.Run("保存失败", func(t *testing.T) {
        repo := NewMockUserRepository()
        repo.saveErr = errors.New("数据库连接失败")
        email := &MockEmailSender{}
        service := NewUserService(repo, email)

        _, err := service.Register(context.Background(), "张三", "zhang@example.com")
        if err == nil {
            t.Fatal("期望错误但没有")
        }

        // 验证邮件没有被发送
        if len(email.sentEmails) != 0 {
            t.Error("保存失败时不应该发送邮件")
        }
    })

    t.Run("邮件发送失败", func(t *testing.T) {
        repo := NewMockUserRepository()
        email := &MockEmailSender{sendErr: errors.New("SMTP 错误")}
        service := NewUserService(repo, email)

        user, err := service.Register(context.Background(), "张三", "zhang@example.com")
        if err != nil {
            t.Fatalf("邮件失败不应该返回错误: %v", err)
        }

        // 但用户应该被保存了
        saved, _ := repo.FindByID(context.Background(), user.ID)
        if saved == nil {
            t.Error("用户应该被保存")
        }
    })
}

使用 testify/mock 简化

如果你觉得手写 Mock 太麻烦,可以用 testify/mock 这样的库:

import "github.com/stretchr/testify/mock"

type MockRepo struct {
    mock.Mock
}

func (m *MockRepo) FindByID(ctx context.Context, id int) (*User, error) {
    args := m.Called(ctx, id)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*User), args.Error(1)
}

func TestGetUser(t *testing.T) {
    repo := new(MockRepo)

    // 设置期望
    expectedUser := &User{ID: 1, Name: "Alice"}
    repo.On("FindByID", mock.Anything, 1).Return(expectedUser, nil)

    user, err := repo.FindByID(context.Background(), 1)
    if err != nil {
        t.Fatal(err)
    }
    if user.Name != "Alice" {
        t.Errorf("name = %s; want Alice", user.Name)
    }

    // 验证期望被满足
    repo.AssertExpectations(t)
}

集成测试:用真实环境验证

单元测试用 Mock 隔离依赖,集成测试则用真实依赖验证整个流程。两者互补,不可偏废。

用构建标签隔离集成测试

集成测试通常比较慢,不应该和单元测试混在一起跑。用构建标签隔离:

//go:build integration

package myapp

import (
    "database/sql"
    "os"
    "testing"

    _ "github.com/lib/pq"
)

func getTestDB(t *testing.T) *sql.DB {
    t.Helper()

    dsn := os.Getenv("TEST_DATABASE_URL")
    if dsn == "" {
        t.Skip("TEST_DATABASE_URL 未设置,跳过集成测试")
    }

    db, err := sql.Open("postgres", dsn)
    if err != nil {
        t.Fatalf("连接数据库失败: %v", err)
    }

    t.Cleanup(func() {
        db.Close()
    })

    return db
}

func TestUserFlow_Integration(t *testing.T) {
    db := getTestDB(t)

    // 清理测试数据
    t.Cleanup(func() {
        db.Exec("DELETE FROM users WHERE name = 'test_user'")
    })

    repo := NewPostgresUserRepo(db)
    service := NewUserService(repo, NewRealEmailSender())

    // 测试完整流程
    user, err := service.Register(context.Background(), "test_user", "test@example.com")
    if err != nil {
        t.Fatalf("注册失败: %v", err)
    }

    // 从数据库验证
    saved, err := repo.FindByID(context.Background(), user.ID)
    if err != nil {
        t.Fatalf("从数据库查询失败: %v", err)
    }
    if saved.Name != "test_user" {
        t.Errorf("数据库中的 name = %s; want test_user", saved.Name)
    }
}

运行方式:

# 只运行单元测试(默认)
go test ./...

# 运行集成测试
TEST_DATABASE_URL="postgres://localhost/testdb" go test -tags=integration ./...

# 运行所有测试
TEST_DATABASE_URL="postgres://localhost/testdb" go test -tags=integration ./...

使用 TestMain 做全局设置

TestMain 在所有测试之前运行一次,适合做全局的初始化和清理:

package myapp

import (
    "fmt"
    "os"
    "testing"
)

func TestMain(m *testing.M) {
    // 全局设置(只运行一次)
    fmt.Println("正在启动测试环境...")

    // 例如:启动 Docker 容器中的测试数据库
    if err := startTestDatabase(); err != nil {
        fmt.Fprintf(os.Stderr, "启动测试数据库失败: %v\n", err)
        os.Exit(1)
    }

    // 运行所有测试
    code := m.Run()

    // 全局清理
    fmt.Println("正在清理测试环境...")
    stopTestDatabase()

    os.Exit(code)
}

基准测试:性能的显微镜

基准测试回答一个关键问题:这段代码有多快?

基础基准测试

package stringutil

import (
    "strings"
    "testing"
)

// ConcatWithPlus 用 + 拼接字符串
func ConcatWithPlus(parts []string) string {
    result := ""
    for _, p := range parts {
        result += p
    }
    return result
}

// ConcatWithBuilder 用 strings.Builder 拼接
func ConcatWithBuilder(parts []string) string {
    var b strings.Builder
    for _, p := range parts {
        b.WriteString(p)
    }
    return b.String()
}

// ConcatWithJoin 用 strings.Join 拼接
func ConcatWithJoin(parts []string) string {
    return strings.Join(parts, "")
}

func BenchmarkConcatWithPlus(b *testing.B) {
    parts := make([]string, 100)
    for i := range parts {
        parts[i] = "hello"
    }
    b.ResetTimer() // 重置计时器,排除准备数据的开销

    for i := 0; i < b.N; i++ {
        ConcatWithPlus(parts)
    }
}

func BenchmarkConcatWithBuilder(b *testing.B) {
    parts := make([]string, 100)
    for i := range parts {
        parts[i] = "hello"
    }
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        ConcatWithBuilder(parts)
    }
}

func BenchmarkConcatWithJoin(b *testing.B) {
    parts := make([]string, 100)
    for i := range parts {
        parts[i] = "hello"
    }
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        ConcatWithJoin(parts)
    }
}

运行基准测试:

$ go test -bench=. -benchmem
BenchmarkConcatWithPlus-8       50000    33218 ns/op    82432 B/op    99 allocs/op
BenchmarkConcatWithBuilder-8   500000     2421 ns/op      512 B/op     1 allocs/op
BenchmarkConcatWithJoin-8     1000000     1103 ns/op      512 B/op     1 allocs/op

结论一目了然:strings.Join 最快,strings.Builder 次之,+ 拼接慢 30 倍,而且内存分配次数是其他两种的 99 倍。

子基准测试

和子测试类似,基准测试也支持子基准:

func BenchmarkConcat(b *testing.B) {
    sizes := []int{10, 100, 1000}

    for _, size := range sizes {
        b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
            parts := make([]string, size)
            for i := range parts {
                parts[i] = "hello"
            }
            b.ResetTimer()

            for i := 0; i < b.N; i++ {
                ConcatWithJoin(parts)
            }
        })
    }
}
$ go test -bench=BenchmarkConcat -benchmem
BenchmarkConcat/size=10-8     5000000     248 ns/op     64 B/op    1 allocs/op
BenchmarkConcat/size=100-8    1000000    1103 ns/op    512 B/op    1 allocs/op
BenchmarkConcat/size=1000-8    200000    9234 ns/op   5120 B/op    1 allocs/op

并行基准测试

func BenchmarkConcurrentMap(b *testing.B) {
    m := NewConcurrentMap()

    b.RunParallel(func(pb *testing.PB) {
        i := 0
        for pb.Next() {
            m.Set(fmt.Sprintf("key-%d", i), i)
            i++
        }
    })
}

b.RunParallel 会在多个 goroutine 中运行基准测试,测试并发性能。

基准测试最佳实践

func BenchmarkExpensiveOperation(b *testing.B) {
    // 1. 准备工作放在 b.ResetTimer() 之前
    data := generateLargeDataset()
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        // 2. 循环体内只放被测代码
        ProcessData(data)
    }

    // 3. 清理工作用 b.Cleanup
    b.Cleanup(func() {
        cleanUp(data)
    })
}

func BenchmarkWithAllocations(b *testing.B) {
    b.ReportAllocs() // 强制报告内存分配(即使没用 -benchmem)

    for i := 0; i < b.N; i++ {
        _ = make([]int, 1000)
    }
}

模糊测试(Fuzz Testing)回顾

在第 63 篇文章中我们详细讲了模糊测试,这里快速回顾一下核心用法:

package myapp

import (
    "strings"
    "testing"
)

// Reverse 反转字符串
func Reverse(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

// FuzzReverse 模糊测试
func FuzzReverse(f *testing.F) {
    // 种子语料库
    f.Add("Hello, World!")
    f.Add("你好世界")
    f.Add("")
    f.Add("a")

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

        // 性质 1:反转两次应该回到原值
        if doubleReversed != input {
            t.Errorf("Reverse(Reverse(%q)) = %q", input, doubleReversed)
        }

        // 性质 2:长度应该不变
        if len([]rune(reversed)) != len([]rune(input)) {
            t.Errorf("len mismatch: %d vs %d",
                len([]rune(reversed)), len([]rune(input)))
        }
    })
}

运行模糊测试:

# 默认模式:只运行种子语料
go test -run FuzzReverse

# 模糊模式:自动生成随机输入
go test -fuzz=FuzzReverse -fuzztime=30s

模糊测试能发现你做梦都想不到的边界情况。强烈建议对每个解析器、编解码器都写一个模糊测试。

测试覆盖率分析

覆盖率不是目的,但它是发现"盲区"的有效工具。

基本覆盖率

# 查看覆盖率百分比
$ go test -cover
ok      myapp    0.025s    coverage: 78.3% of statements

# 生成详细报告
$ go test -coverprofile=coverage.out

# 在浏览器中查看(彩色高亮哪些代码被覆盖了)
$ go tool cover -html=coverage.out

浏览器会打开一个漂亮的页面,绿色表示已覆盖,红色表示未覆盖。

函数级覆盖率

$ go tool cover -func=coverage.out
myapp/user.go:15:    NewUser          100.0%
myapp/user.go:25:    Validate          85.7%
myapp/user.go:50:    Save             100.0%
myapp/user.go:70:    Delete            66.7%
myapp/email.go:10:   SendEmail          0.0%
total:               (statements)      78.3%

一眼就能看到哪些函数没有被测试覆盖。

在 CI 中强制覆盖率

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'

      - name: Run tests with coverage
        run: |
          go test -coverprofile=coverage.out -covermode=atomic ./...

      - name: Check coverage threshold
        run: |
          coverage=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
          echo "Coverage: ${coverage}%"
          if (( $(echo "$coverage < 80" | bc -l) )); then
            echo "覆盖率低于 80%!"
            exit 1
          fi

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage.out

CI/CD 集成:完整的测试流水线

一个完整的 Go 项目 CI/CD 流水线应该包含这些步骤:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'
      - name: golangci-lint
        uses: golangci/golangci-lint-action@v6
        with:
          version: latest

  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'

      - name: Unit tests
        run: go test -race -coverprofile=coverage.out ./...

      - name: Integration tests
        env:
          TEST_DATABASE_URL: postgres://postgres:test@localhost:5432/testdb?sslmode=disable
        run: go test -tags=integration -race ./...

      - name: Benchmarks
        run: go test -bench=. -benchmem -run=^$ ./...

  build:
    needs: [lint, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'
      - name: Build
        run: go build -o myapp ./cmd/myapp

测试脚本:一键跑全部

#!/bin/bash
# scripts/test.sh

set -e

echo "🔍 Running linter..."
golangci-lint run ./...

echo "🧪 Running unit tests..."
go test -race -coverprofile=coverage.out ./...

echo "📊 Coverage report:"
go tool cover -func=coverage.out | grep total

echo "🏗️ Running integration tests..."
TEST_DATABASE_URL="postgres://localhost/testdb" \
  go test -tags=integration -race ./...

echo "⚡ Running benchmarks..."
go test -bench=. -benchmem -run=^$ -benchtime=1s ./...

echo "✅ All tests passed!"

测试组织:大型项目的最佳实践

目录结构

myapp/
├── cmd/
│   └── myapp/
│       └── main.go
├── internal/
│   ├── user/
│   │   ├── user.go
│   │   ├── user_test.go          # 单元测试
│   │   ├── repository.go
│   │   ├── repository_test.go
│   │   ├── service.go
│   │   └── service_test.go
│   └── order/
│       ├── order.go
│       └── order_test.go
├── integration/
│   └── user_flow_test.go         # 集成测试
├── testdata/
│   └── fixtures/
└── scripts/
    └── test.sh

测试命名约定

// 函数名_场景_期望结果
func TestUserService_Register_WithValidInput_CreatesUser(t *testing.T) {}
func TestUserService_Register_WithEmptyName_ReturnsError(t *testing.T) {}
func TestUserService_Register_WhenDBFails_ReturnsError(t *testing.T) {}

// 或者用子测试:
func TestUserService_Register(t *testing.T) {
    t.Run("valid input creates user", func(t *testing.T) { ... })
    t.Run("empty name returns error", func(t *testing.T) { ... })
    t.Run("db failure returns error", func(t *testing.T) { ... })
}

测试分层策略

┌─────────────────────────────┐
│  E2E 测试(少量,慢)         │  测试完整用户流程
├─────────────────────────────┤
│  集成测试(适量,中速)        │  测试组件间的交互
├─────────────────────────────┤
│  单元测试(大量,快)          │  测试单个函数/方法
└─────────────────────────────┘

测试金字塔

  • 底层大量单元测试:快速、隔离、易维护
  • 中层适量集成测试:验证组件间的协作
  • 顶层少量 E2E 测试:验证关键用户流程

测试中的常见反模式

1. 测试依赖执行顺序

// ❌ 错误:测试之间有依赖
var userID int

func TestCreateUser(t *testing.T) {
    user := createUser("Alice")
    userID = user.ID // 保存到包级变量
}

func TestGetUser(t *testing.T) {
    user := getUser(userID) // 依赖上一个测试的结果
}

每个测试应该独立,可以单独运行,也可以乱序运行。

2. 断言太少

// ❌ 错误:只检查了不 panic,没检查结果
func TestCreateUser(t *testing.T) {
    user, _ := CreateUser("Alice")
    _ = user // 什么都没检查
}

// ✅ 正确:检查所有重要字段
func TestCreateUser(t *testing.T) {
    user, err := CreateUser("Alice")
    if err != nil {
        t.Fatal(err)
    }
    if user.Name != "Alice" {
        t.Errorf("name = %s; want Alice", user.Name)
    }
    if user.ID == 0 {
        t.Error("ID should be assigned")
    }
}

3. 忽略错误

// ❌ 错误:用 _ 忽略了错误
result, _ := doSomething()

// ✅ 正确:总是检查错误
result, err := doSomething()
if err != nil {
    t.Fatalf("unexpected error: %v", err)
}

小结

今天我们系统学习了 Go 测试的进阶技术:

测试模式:

  1. 表驱动测试:Go 社区的黄金模式
  2. 子测试t.Run 让测试更清晰
  3. 测试辅助t.Helper() + t.Cleanup()
  4. 测试 Fixturestestdata 目录和黄金文件

隔离与集成:
5. Mock 模式:基于接口的依赖隔离
6. 集成测试:构建标签隔离 + TestMain 全局设置

性能与质量:
7. 基准测试BenchmarkXxx + b.RunParallel
8. 模糊测试:自动生成随机输入发现隐藏 Bug
9. 覆盖率分析:发现测试盲区

工程化:
10. CI/CD 集成:完整的测试流水线
11. 测试组织:目录结构和命名约定
12. 反模式:避免常见的测试陷阱

测试的核心原则:

  • 测试是投资,不是成本。现在花时间写测试,将来少花时间 debug
  • 每个测试只验证一件事
  • 测试应该快速、独立、可重复
  • 不要只测成功路径,错误路径同样重要
  • 基准测试回答"有多快",模糊测试回答"有多健壮"

Go 的测试工具简洁而强大,不需要任何第三方框架就能构建完整的测试体系。把这篇文章的知识用起来,你的代码质量会有质的飞跃。

练习时间

  1. 表驱动改造:把你现有的一个测试文件改造成表驱动模式
  2. Mock 练习:为一个依赖外部 API 的函数写 Mock 测试
  3. 基准测试:对比 sync.Mutexsync.RWMutex 在不同读写比例下的性能
  4. 模糊测试:为你的 JSON 解析器写一个模糊测试
  5. CI 流水线:为你的项目配置完整的 GitHub Actions 测试流水线

参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页