Skip to content

6.7 Go 单元测试完全指南

1. 为什么需要单元测试?

在软件开发中,单元测试是保证代码质量的重要手段。通过编写测试用例,我们可以:

  • ✅ 及早发现和修复 bug
  • ✅ 确保代码重构后功能不被破坏
  • ✅ 提供代码的使用示例
  • ✅ 提高代码的可维护性

Go 语言内置了testing 包,让单元测试变得简单而优雅。

2. 单元测试基础

2.1 测试文件命名规则

Go 的测试文件必须遵循以下规则:

  • 测试文件以 _test.go 结尾
  • 测试文件与被测试代码在同一个包(package)中
  • 测试函数必须以 Test 开头,后跟大写字母

示例:

项目结构:
myproject/
  ├── split.go         # 业务代码
  └── split_test.go    # 测试代码

2.2 第一个测试函数

假设我们有一个字符串分割函数 Split

go
// split.go
package split

import "strings"

// Split 分割字符串
func Split(s, sep string) []string {
    var result []string
    index := strings.Index(s, sep)
    
    for index >= 0 {
        result = append(result, s[:index])
        s = s[index+len(sep):]
        index = strings.Index(s, sep)
    }
    result = append(result, s)
    
    return result
}

为它编写测试:

go
// split_test.go
package split

import (
    "reflect"
    "testing"
)

func TestSplit(t *testing.T) {
    got := Split("a:b:c", ":")
    want := []string{"a", "b", "c"}
    
    if !reflect.DeepEqual(got, want) {
        t.Errorf("期望:%v,实际:%v", want, got)
    }
}

2.3 运行测试

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

# 显示详细测试信息
go test -v

# 运行指定的测试函数
go test -run TestSplit

# 查看测试覆盖率
go test -cover

输出示例:

=== RUN   TestSplit
--- PASS: TestSplit (0.00s)
PASS
ok      split    0.006s

3. testing.T 的常用方法

在编写测试时,testing.T 类型提供了多种方法来报告测试结果、记录日志等。

3.1 日志输出:t.Log / t.Logf

用于输出测试日志,只有在测试失败或使用 -v 参数时才会显示

go
func TestLog(t *testing.T) {
    t.Log("这是一条普通日志")
    t.Logf("格式化日志:%s = %d", "count", 42)
}

3.2 错误报告:t.Error / t.Errorf

报告测试失败,但继续执行后续代码。适合检查多个独立条件。

go
func TestMultipleChecks(t *testing.T) {
    result := Calculate(2, 3)
    
    if result != 5 {
        t.Errorf("Calculate(2, 3) = %d, want 5", result)
    }
    
    // 即使上面失败,这里依然会执行
    if result < 0 {
        t.Error("结果不应该为负数")
    }
}

3.3 致命错误:t.Fatal / t.Fatalf

报告测试失败,并立即终止测试。适合前置条件失败的场景。

go
func TestDatabase(t *testing.T) {
    db, err := OpenDatabase()
    if err != nil {
        t.Fatalf("无法打开数据库: %v", err)
        // 后面的代码不会执行
    }
    defer db.Close()
    
    // 数据库打开成功后才会执行到这里
}

3.4 Error 与 Fatal 的选择

方法行为使用场景
t.Error/Errorf报告错误,继续执行检查多个独立条件
t.Fatal/Fatalf报告错误,立即退出前置条件失败,后续无法继续

示例对比:

go
// 使用 Error:检查多个字段
func TestPerson(t *testing.T) {
    person := GetPerson()
    
    if person.Name != "Alice" {
        t.Errorf("Name = %s, want Alice", person.Name)
    }
    
    if person.Age != 30 {
        t.Errorf("Age = %d, want 30", person.Age)
    }
    // 所有错误都会被报告
}

// 使用 Fatal:前置条件失败
func TestAPI(t *testing.T) {
    client, err := NewClient()
    if err != nil {
        t.Fatalf("创建客户端失败: %v", err)
    }
    // client 为 nil 时后续测试无意义
}

3.5 跳过测试:t.Skip / t.Skipf

条件不满足时跳过测试。

go
func TestLinuxOnly(t *testing.T) {
    if runtime.GOOS != "linux" {
        t.Skip("此测试仅在 Linux 下运行")
    }
    // 测试代码...
}

func TestIntegration(t *testing.T) {
    if testing.Short() {
        t.Skip("跳过耗时测试(-short 模式)")
    }
    // 集成测试代码...
}

3.6 辅助函数:t.Helper

标记测试辅助函数,让错误信息更准确。

go
// 辅助函数
func assertEqual(t *testing.T, got, want int) {
    t.Helper()  // 标记为辅助函数
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

// 测试中使用
func TestCalculate(t *testing.T) {
    result := Calculate(2, 3)
    assertEqual(t, result, 5)  // 错误会报告在这一行,而不是 assertEqual 内部
}

3.7 常用方法速查

方法说明典型用法
t.Log/Logf输出日志(需要 -v)调试信息
t.Error/Errorf报告错误,继续执行多个独立检查
t.Fatal/Fatalf报告错误,立即终止前置条件失败
t.Skip/Skipf跳过测试条件不满足
t.Helper标记辅助函数提取公共验证逻辑

3.8 最佳实践

go
// ✅ 好的做法:提供详细的错误信息
t.Errorf("Split(%q, %q) = %v, want %v", input, sep, got, want)

// ❌ 不好的做法:错误信息不明确
t.Error("failed")

// ✅ 好的做法:前置条件使用 Fatal
db, err := OpenDB()
if err != nil {
    t.Fatalf("无法打开数据库: %v", err)
}

// ✅ 好的做法:辅助函数使用 Helper
func assertNoError(t *testing.T, err error) {
    t.Helper()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}

4. 表格驱动测试(Table-Driven Tests)

4.1 什么是表格驱动测试?

表格驱动测试是 Go 社区推荐的最佳实践,它将多个测试用例组织成一个表格(切片或 map),然后循环执行。

优点:

  • 减少重复代码
  • 易于添加新的测试用例
  • 测试逻辑清晰明了

4.2 使用切片的表格驱动测试

go
func TestSplit(t *testing.T) {
    // 定义测试用例结构体
    type testCase struct {
        input string
        sep   string
        want  []string
    }
    
    // 测试用例表格
    tests := []testCase{
        {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
        {input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
        {input: "abcd", sep: "bc", want: []string{"a", "d"}},
        {input: "枯藤老树昏鸦", sep: "老", want: []string{"枯藤", "树昏鸦"}},
    }
    
    // 遍历测试用例
    for _, tc := range tests {
        got := Split(tc.input, tc.sep)
        if !reflect.DeepEqual(got, tc.want) {
            t.Errorf("Split(%q, %q) = %v, want %v", 
                     tc.input, tc.sep, got, tc.want)
        }
    }
}

4.3 使用 map 的表格驱动测试

使用 map 可以为每个测试用例命名,让测试结果更清晰:

go
func TestSplit(t *testing.T) {
    type testCase struct {
        input string
        sep   string
        want  []string
    }
    
    // 使用 map 存储测试用例,key 为测试用例名称
    tests := map[string]testCase{
        "simple":       {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
        "wrong_sep":    {input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
        "multi_char":   {input: "abcd", sep: "bc", want: []string{"a", "d"}},
        "chinese":      {input: "枯藤老树昏鸦", sep: "老", want: []string{"枯藤", "树昏鸦"}},
    }
    
    for name, tc := range tests {
        got := Split(tc.input, tc.sep)
        if !reflect.DeepEqual(got, tc.want) {
            t.Errorf("[%s] Split(%q, %q) = %v, want %v", 
                     name, tc.input, tc.sep, got, tc.want)
        }
    }
}

5. 子测试(Subtests)

5.1 为什么需要子测试?

使用 t.Run() 可以将每个测试用例作为独立的子测试运行,好处是:

  • 可以单独运行某个子测试
  • 测试失败时更容易定位问题
  • 支持并行测试

5.2 子测试示例

go
func TestSplit(t *testing.T) {
    type testCase struct {
        input string
        sep   string
        want  []string
    }
    
    tests := map[string]testCase{
        "simple":     {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
        "wrong_sep":  {input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
        "multi_char": {input: "abcd", sep: "bc", want: []string{"a", "d"}},
    }
    
    for name, tc := range tests {
        t.Run(name, func(t *testing.T) {
            got := Split(tc.input, tc.sep)
            if !reflect.DeepEqual(got, tc.want) {
                t.Errorf("expected: %v, got: %v", tc.want, got)
            }
        })
    }
}

运行指定子测试:

bash
# 运行 TestSplit 下的 simple 子测试
go test -run TestSplit/simple -v

# 运行所有名称包含 "char" 的子测试
go test -run TestSplit/char -v

输出示例:

=== RUN   TestSplit
=== RUN   TestSplit/simple
=== RUN   TestSplit/wrong_sep
=== RUN   TestSplit/multi_char
--- PASS: TestSplit (0.00s)
    --- PASS: TestSplit/simple (0.00s)
    --- PASS: TestSplit/wrong_sep (0.00s)
    --- PASS: TestSplit/multi_char (0.00s)
PASS

5.3 并行测试

使用 t.Parallel() 可以让子测试并行执行,提高测试速度:

go
func TestSplitParallel(t *testing.T) {
    tests := map[string]struct{
        input string
        sep   string
        want  []string
    }{
        "simple":  {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
        "chinese": {input: "枯藤老树昏鸦", sep: "老", want: []string{"枯藤", "树昏鸦"}},
    }
    
    for name, tc := range tests {
        tc := tc // 注意:并行测试需要重新赋值
        t.Run(name, func(t *testing.T) {
            t.Parallel() // 标记为并行测试
            got := Split(tc.input, tc.sep)
            if !reflect.DeepEqual(got, tc.want) {
                t.Errorf("expected: %v, got: %v", tc.want, got)
            }
        })
    }
}

6. 测试辅助函数

6.1 使用 t.Helper()

当测试代码中有重复的验证逻辑时,可以提取成辅助函数,使用 t.Helper() 标记:

go
// 测试辅助函数
func assertResult(t *testing.T, got, want []string) {
    t.Helper() // 标记为辅助函数,错误时会报告调用者的行号
    if !reflect.DeepEqual(got, want) {
        t.Errorf("expected: %v, got: %v", want, got)
    }
}

func TestSplit(t *testing.T) {
    tests := []struct {
        input string
        sep   string
        want  []string
    }{
        {"a:b:c", ":", []string{"a", "b", "c"}},
        {"a:b:c", ",", []string{"a:b:c"}},
    }
    
    for _, tc := range tests {
        got := Split(tc.input, tc.sep)
        assertResult(t, got, tc.want)
    }
}

7. 基准测试(Benchmark)

7.1 什么是基准测试?

基准测试用于测试代码的性能,帮助我们:

  • 测量函数的执行时间
  • 对比不同实现的性能
  • 发现性能瓶颈

7.2 编写基准测试

基准测试函数以 Benchmark 开头,接收 *testing.B 参数:

go
func BenchmarkSplit(b *testing.B) {
    // b.N 会自动调整,以获得可靠的测试结果
    for i := 0; i < b.N; i++ {
        Split("枯藤老树昏鸦", "老")
    }
}

运行基准测试:

bash
# 运行所有基准测试
go test -bench=.

# 运行指定基准测试
go test -bench=BenchmarkSplit

# 显示内存分配情况
go test -bench=. -benchmem

输出示例:

BenchmarkSplit-8    10000000    131 ns/op    48 B/op    3 allocs/op

解读:

  • BenchmarkSplit-8:测试名称-CPU核心数
  • 10000000:执行了 1000 万次
  • 131 ns/op:每次操作耗时 131 纳秒
  • 48 B/op:每次操作分配 48 字节内存
  • 3 allocs/op:每次操作进行 3 次内存分配

7.3 性能优化实战

假设我们发现 Split 函数性能不佳,可以优化为:

go
// 优化版本:预分配切片容量
func SplitV2(s, sep string) []string {
    result := make([]string, 0, strings.Count(s, sep)+1)
    index := strings.Index(s, sep)
    
    for index >= 0 {
        result = append(result, s[:index])
        s = s[index+len(sep):]
        index = strings.Index(s, sep)
    }
    result = append(result, s)
    
    return result
}

func BenchmarkSplitV2(b *testing.B) {
    for i := 0; i < b.N; i++ {
        SplitV2("枯藤老树昏鸦", "老")
    }
}

对比两个版本的性能:

bash
go test -bench=. -benchmem

7.4 重置计时器

如果基准测试前需要做一些准备工作,可以使用 b.ResetTimer() 重置计时器:

go
func BenchmarkSplitWithSetup(b *testing.B) {
    // 准备测试数据(不计入性能测试时间)
    testData := generateLargeString()
    
    b.ResetTimer() // 重置计时器
    
    for i := 0; i < b.N; i++ {
        Split(testData, ":")
    }
}

7.5 并行基准测试

测试在多核 CPU 下的性能表现:

go
func BenchmarkSplitParallel(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            Split("枯藤老树昏鸦", "老")
        }
    })
}

8. 测试覆盖率

8.1 查看测试覆盖率

bash
# 显示覆盖率百分比
go test -cover

# 生成覆盖率报告
go test -coverprofile=coverage.out

# 查看详细的覆盖率报告
go tool cover -html=coverage.out

8.2 分析覆盖率报告

执行后会在浏览器中打开 HTML 报告:

  • 绿色:被测试覆盖的代码
  • 红色:未被测试覆盖的代码
  • 灰色:不需要测试的代码(如注释)

覆盖率建议:

  • 核心业务逻辑:≥ 80%
  • 工具函数:≥ 90%
  • 边界情况和错误处理:100%

9. 测试技巧和最佳实践

9.1 表格驱动测试的高级用法

技巧 1:使用匿名结构体简化代码

go
func TestSplit(t *testing.T) {
    tests := []struct {
        name  string
        input string
        sep   string
        want  []string
    }{
        {"正常分割", "a:b:c", ":", []string{"a", "b", "c"}},
        {"分隔符不存在", "abc", ":", []string{"abc"}},
        {"空字符串", "", ":", []string{""}},
        {"连续分隔符", "a::b", ":", []string{"a", "", "b"}},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Split(tt.input, tt.sep)
            if !reflect.DeepEqual(got, tt.want) {
                t.Errorf("got %v, want %v", got, tt.want)
            }
        })
    }
}

技巧 2:测试错误情况

go
func TestSplitError(t *testing.T) {
    tests := []struct {
        name      string
        input     string
        sep       string
        wantError bool
    }{
        {"空分隔符", "abc", "", true},
        {"分隔符太长", "ab", "abc", true},
        {"正常情况", "a:b", ":", false},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := SplitWithError(tt.input, tt.sep)
            if (err != nil) != tt.wantError {
                t.Errorf("wantError = %v, got error = %v", tt.wantError, err)
            }
        })
    }
}

9.2 Mock 和依赖注入

对于依赖外部服务的代码,使用接口进行 Mock:

go
// 定义接口
type DataStore interface {
    Get(key string) (string, error)
    Set(key, value string) error
}

// 业务逻辑
func ProcessData(store DataStore, key string) error {
    value, err := store.Get(key)
    if err != nil {
        return err
    }
    // 处理数据...
    return store.Set(key, value)
}

// Mock 实现
type MockStore struct {
    data map[string]string
}

func (m *MockStore) Get(key string) (string, error) {
    if v, ok := m.data[key]; ok {
        return v, nil
    }
    return "", errors.New("not found")
}

func (m *MockStore) Set(key, value string) error {
    m.data[key] = value
    return nil
}

// 测试
func TestProcessData(t *testing.T) {
    mock := &MockStore{
        data: map[string]string{"key1": "value1"},
    }
    
    err := ProcessData(mock, "key1")
    if err != nil {
        t.Errorf("unexpected error: %v", err)
    }
}

9.3 测试技巧总结

技巧说明
AAA 模式Arrange(准备)→ Act(执行)→ Assert(断言)
单一职责每个测试只测试一个功能点
独立性测试之间不应有依赖关系
可重复性测试结果应该是可重复的
快速执行单元测试应该快速完成
清晰命名测试名称应清楚表达测试意图

9.4 常用断言方法

go
// 基本断言
if got != want {
    t.Errorf("got %v, want %v", got, want)
}

// 切片、map 比较
if !reflect.DeepEqual(got, want) {
    t.Errorf("got %v, want %v", got, want)
}

// 错误断言
if err != nil {
    t.Fatalf("unexpected error: %v", err)
}

// 空值断言
if result == nil {
    t.Error("result should not be nil")
}

// 布尔断言
if !condition {
    t.Error("condition should be true")
}

10. 总结

10.1 测试类型对比

测试类型函数前缀参数用途
单元测试Test*testing.T验证功能正确性
基准测试Benchmark*testing.B测试性能
示例函数Example文档和示例

10.2 常用命令速查

bash
# 基本测试命令
go test                              # 运行当前目录测试
go test ./...                        # 运行所有包的测试
go test -v                          # 显示详细输出
go test -run TestName               # 运行指定测试
go test -run TestName/SubTest       # 运行指定子测试

# 覆盖率
go test -cover                      # 显示覆盖率
go test -coverprofile=c.out         # 生成覆盖率文件
go tool cover -html=c.out           # 查看 HTML 覆盖率报告

# 基准测试
go test -bench=.                    # 运行所有基准测试
go test -bench=BenchName            # 运行指定基准测试
go test -bench=. -benchmem          # 显示内存分配
go test -bench=. -cpuprofile=cpu.out  # 生成 CPU profile

# 其他选项
go test -race                       # 检测竞态条件
go test -timeout 30s                # 设置超时时间
go test -parallel 4                 # 设置并行数
go test -count=10                   # 重复运行 10 次

10.3 学习资源

11. 练习题

练习 1:基础测试

为以下函数编写单元测试:

go
// 判断是否为回文串
func IsPalindrome(s string) bool {
    runes := []rune(s)
    for i := 0; i < len(runes)/2; i++ {
        if runes[i] != runes[len(runes)-1-i] {
            return false
        }
    }
    return true
}

练习 2:表格驱动测试

为以下函数编写表格驱动测试:

go
// 计算阶乘
func Factorial(n int) int {
    if n < 0 {
        return -1
    }
    if n == 0 {
        return 1
    }
    return n * Factorial(n-1)
}

练习 3:基准测试

对比以下两种字符串拼接方式的性能:

go
// 方式1:使用 + 拼接
func ConcatWithPlus(strs []string) string {
    result := ""
    for _, s := range strs {
        result += s
    }
    return result
}

// 方式2:使用 strings.Builder
func ConcatWithBuilder(strs []string) string {
    var builder strings.Builder
    for _, s := range strs {
        builder.WriteString(s)
    }
    return builder.String()
}