Go 单元测试:结构与最佳实践

从基础到高级模式的 Go 测试

目录

Go内置的testing包 提供了一个强大且极简的框架,用于编写无需外部依赖的单元测试。 以下是Go测试的基础知识、项目结构和高级模式,用于构建可靠的Go应用程序。

Go单元测试很酷

为什么在Go中测试很重要

Go的哲学强调简单性和可靠性。标准库包含testing包,使单元测试在Go生态系统中成为一等公民。良好的Go测试代码可以提高可维护性,尽早发现错误,并通过示例提供文档。如果你是Go的新手,请查看我们的Go速查表,以快速参考语言基础知识。

Go测试的主要优势:

  • 内置支持:无需外部框架
  • 执行速度快:默认并发执行测试
  • 语法简单:最小的样板代码
  • 丰富的工具:覆盖率报告、基准测试和性能分析
  • CI/CD友好:易于与自动化流水线集成

Go测试的项目结构

Go测试与生产代码并存,具有清晰的命名约定:

myproject/
├── go.mod
├── main.go
├── calculator.go
├── calculator_test.go
├── utils/
│   ├── helper.go
│   └── helper_test.go
└── models/
    ├── user.go
    └── user_test.go

关键约定:

  • 测试文件以_test.go结尾
  • 测试与代码在同一包中(或使用_test后缀进行黑盒测试)
  • 每个源文件可以有一个对应的测试文件

包测试方法

白盒测试(同一包):

package calculator

import "testing"
// 可以访问未导出的函数和变量

黑盒测试(外部包):

package calculator_test

import (
    "testing"
    "myproject/calculator"
)
// 只能访问导出的函数(推荐用于公共API)

基本测试结构

每个测试函数都遵循以下模式:

package calculator

import "testing"

// 测试函数必须以"Test"开头
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5
    
    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
}

Testing.T方法:

  • t.Error() / t.Errorf():标记测试失败但继续执行
  • t.Fatal() / t.Fatalf():标记测试失败并立即停止
  • t.Log() / t.Logf():记录输出(仅在使用-v标志时显示)
  • t.Skip() / t.Skipf():跳过测试
  • t.Parallel():与其他并行测试并行运行

表驱动测试:Go的方式

表驱动测试是Go中测试多种场景的惯用方式。使用Go泛型,你还可以创建类型安全的测试助手,适用于不同的数据类型:

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"加法", 2, 3, "+", 5, false},
        {"减法", 5, 3, "-", 2, false},
        {"乘法", 4, 3, "*", 12, false},
        {"除法", 10, 2, "/", 5, false},
        {"除以零", 10, 0, "/", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := Calculate(tt.a, tt.b, tt.op)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if result != tt.expected {
                t.Errorf("Calculate(%d, %d, %q) = %d; want %d", 
                    tt.a, tt.b, tt.op, result, tt.expected)
            }
        })
    }
}

优势:

  • 一个测试函数处理多个场景
  • 易于添加新测试用例
  • 清晰地记录预期行为
  • 更好的测试组织和可维护性

运行测试

基本命令

# 运行当前目录下的测试
go test

# 以详细模式运行测试
go test -v

# 运行所有子目录下的测试
go test ./...

# 运行特定测试
go test -run TestAdd

# 运行匹配模式的测试
go test -run TestCalculate/addition

# 并行运行测试(默认是GOMAXPROCS)
go test -parallel 4

# 带超时运行测试
go test -timeout 30s

测试覆盖率

# 带覆盖率运行测试
go test -cover

# 生成覆盖率文件
go test -coverprofile=coverage.out

# 在浏览器中查看覆盖率
go tool cover -html=coverage.out

# 显示按函数的覆盖率
go tool cover -func=coverage.out

# 设置覆盖率模式(set, count, atomic)
go test -covermode=count -coverprofile=coverage.out

有用的标志

  • -short:运行标记为if testing.Short()的测试
  • -race:启用竞态检测器(查找并发访问问题)
  • -cpu:指定GOMAXPROCS值
  • -count n:运行每个测试n次
  • -failfast:在第一个测试失败时停止

测试助手和设置/清理

助手函数

使用t.Helper()标记助手函数以改进错误报告:

func assertEqual(t *testing.T, got, want int) {
    t.Helper() // 此行被报告为调用者
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMath(t *testing.T) {
    result := Add(2, 3)
    assertEqual(t, result, 5) // 错误行指向此处
}

设置和清理

func TestMain(m *testing.M) {
    // 设置代码
    setup()
    
    // 运行测试
    code := m.Run()
    
    // 清理代码
    teardown()
    
    os.Exit(code)
}

测试固定装置

func setupTestCase(t *testing.T) func(t *testing.T) {
    t.Log("设置测试用例")
    return func(t *testing.T) {
        t.Log("清理测试用例")
    }
}

func TestSomething(t *testing.T) {
    teardown := setupTestCase(t)
    defer teardown(t)
    
    // 测试代码
}

模拟和依赖注入

基于接口的模拟

当测试与数据库交互的代码时,使用接口可以轻松创建模拟实现。如果你在Go中使用PostgreSQL,请查看我们的Go ORM比较,以选择具有良好可测试性的数据库库。

// 生产代码
type Database interface {
    GetUser(id int) (*User, error)
}

type UserService struct {
    db Database
}

func (s *UserService) GetUserName(id int) (string, error) {
    user, err := s.db.GetUser(id)
    if err != nil {
        return "", err
    }
    return user.Name, nil
}

// 测试代码
type MockDatabase struct {
    users map[int]*User
}

func (m *MockDatabase) GetUser(id int) (*User, error) {
    if user, ok := m.users[id]; ok {
        return user, nil
    }
    return nil, errors.New("用户未找到")
}

func TestGetUserName(t *testing.T) {
    mockDB := &MockDatabase{
        users: map[int]*User{
            1: {ID: 1, Name: "Alice"},
        },
    }
    
    service := &UserService{db: mockDB}
    name, err := service.GetUserName(1)
    
    if err != nil {
        t.Fatalf("出现意外错误: %v", err)
    }
    if name != "Alice" {
        t.Errorf("得到 %s, 想要 Alice", name)
    }
}

流行的测试库

Testify

最受欢迎的Go测试库,用于断言和模拟:

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestWithTestify(t *testing.T) {
    result := Add(2, 3)
    assert.Equal(t, 5, result, "它们应该相等")
    assert.NotNil(t, result)
}

// 模拟示例
type MockDB struct {
    mock.Mock
}

func (m *MockDB) GetUser(id int) (*User, error) {
    args := m.Called(id)
    return args.Get(0).(*User), args.Error(1)
}

其他工具

  • gomock:Google的带有代码生成的模拟框架
  • httptest:用于测试HTTP处理器的标准库
  • testcontainers-go:使用Docker容器进行集成测试
  • ginkgo/gomega:BDD风格的测试框架

当测试与外部服务(如AI模型)的集成时,你需要模拟或存根这些依赖项。例如,如果你在Go中使用Ollama,请考虑创建接口包装器,使代码更易于测试。

基准测试

Go内置了对基准测试的支持:

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}

// 运行基准测试
// go test -bench=. -benchmem

输出显示每秒的迭代次数和内存分配。

最佳实践

  1. 编写表驱动测试:使用结构体切片模式处理多个测试用例
  2. 使用t.Run进行子测试:更好的组织方式,可以选择性地运行子测试
  3. 优先测试导出的函数:专注于公共API的行为
  4. 保持测试简单:每个测试应验证一件事
  5. 使用有意义的测试名称:描述正在测试的内容和预期结果
  6. 不要测试实现细节:测试行为,而不是内部实现
  7. 使用接口作为依赖项:使模拟更容易
  8. 追求高覆盖率,但质量重于数量:100%覆盖率并不意味着没有错误
  9. 使用-race标志运行测试:尽早发现并发问题
  10. 使用TestMain进行昂贵的设置:避免在每个测试中重复设置

示例:完整的测试套件

package user

import (
    "errors"
    "testing"
)

type User struct {
    ID    int
    Name  string
    Email string
}

func ValidateUser(u *User) error {
    if u.Name == "" {
        return errors.New("名字不能为空")
    }
    if u.Email == "" {
        return errors.New("电子邮件不能为空")
    }
    return nil
}

// 测试文件:user_test.go
func TestValidateUser(t *testing.T) {
    tests := []struct {
        name    string
        user    *User
        wantErr bool
        errMsg  string
    }{
        {
            name:    "有效用户",
            user:    &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
            wantErr: false,
        },
        {
            name:    "空名字",
            user:    &User{ID: 1, Name: "", Email: "alice@example.com"},
            wantErr: true,
            errMsg:  "名字不能为空",
        },
        {
            name:    "空电子邮件",
            user:    &User{ID: 1, Name: "Alice", Email: ""},
            wantErr: true,
            errMsg:  "电子邮件不能为空",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ValidateUser(tt.user)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if err != nil && err.Error() != tt.errMsg {
                t.Errorf("ValidateUser() error message = %v, want %v", err.Error(), tt.errMsg)
            }
        })
    }
}

有用的链接

结论

Go的测试框架提供了所有必要的功能,用于进行全面的单元测试,且设置简单。通过遵循Go的惯用法,如表驱动测试,使用接口进行模拟,并利用内置工具,你可以创建可维护、可靠的测试套件,与你的代码库一起成长。

这些测试实践适用于所有类型的Go应用程序,从Web服务到使用Cobra & Viper构建的CLI应用程序。测试命令行工具需要类似的模式,但需要额外关注输入/输出和标志解析的测试。

从简单的测试开始,逐步增加覆盖率,记住测试是对代码质量和开发者信心的投资。Go社区对测试的重视使得长期维护项目和与团队成员有效协作变得更加容易。