跳转至

📖 测试

学习时间: 约 4-5 小时 | 难度: ⭐⭐⭐ 中级 | 前置知识: Go基础语法、包管理

📚 章节概述

Go 在语言层面内建了测试支持——go test 命令和 testing 包。无需任何第三方框架就能编写单元测试、基准测试、模糊测试和示例测试。本章将全面讲解 Go 的测试体系,从基础测试到高级技巧,帮助你构建可靠的测试套件。

Go测试金字塔与工具链

上图概括了从单元测试到集成测试的层次结构,并对应 Go 原生测试工具链。

🎯 学习目标

  • 掌握单元测试和表驱动测试
  • 学会子测试、并行测试和 TestMain
  • 精通基准测试和性能分析
  • 了解模糊测试(Go 1.18+)
  • 掌握 Mock、Stub 等测试技巧
  • 理解测试覆盖率和 CI 集成

📌 概念:单元测试基础

1.1 测试文件命名约定

Text Only
math.go          → math_test.go       // 同包测试
handler.go       → handler_test.go     // 同包测试
calc/calc.go     → calc/calc_test.go   // 同包测试

// 黑盒测试(不同包名)
// 文件: math_test.go
package math_test  // 注意包名带 _test 后缀

import "github.com/myproject/math"

func TestAdd(t *testing.T) {
    result := math.Add(2, 3)  // 只能访问导出函数
    // ...
}

1.2 编写基本测试

Go
// math.go
package math

func Add(a, b int) int {
    return a + b
}

func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}
Go
// math_test.go
package math

import (
    "testing"
)

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

func TestDivide(t *testing.T) {
    result, err := Divide(10, 2)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if result != 5.0 {
        t.Errorf("Divide(10, 2) = %.1f; want 5.0", result)
    }
}

func TestDivideByZero(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Error("Divide(10, 0) should return error")
    }
}

1.3 testing.T 的常用方法

Go
func TestExample(t *testing.T) {
    // t.Error / t.Errorf — 报告错误但继续执行
    t.Errorf("期望 %d,得到 %d", expected, actual)

    // t.Fatal / t.Fatalf — 报告错误并立即停止当前测试
    t.Fatalf("初始化失败: %v", err)

    // t.Log / t.Logf — 输出日志(仅 -v 模式可见)
    t.Logf("测试数据: %v", data)

    // t.Skip / t.Skipf — 跳过测试
    if os.Getenv("INTEGRATION") == "" {
        t.Skip("跳过集成测试:未设置 INTEGRATION 环境变量")
    }

    // t.Helper — 标记为辅助函数(错误报告时显示调用者行号)
    // t.Cleanup — 注册清理函数(测试结束后执行)
    // t.TempDir — 返回一个测试专用临时目录
}

1.4 运行测试

Bash
# 基本运行
go test                        # 当前包
go test ./...                  # 所有包(递归)
go test ./internal/...         # 特定目录下的包

# 指定测试
go test -run TestAdd           # 名称匹配
go test -run "TestDivide.*"    # 正则匹配
go test -run TestAdd/positive  # 子测试

# 输出选项
go test -v                     # 详细输出
go test -v -count=1            # 禁用缓存
go test -short                 # 短模式(跳过耗时测试)

# 超时和并行
go test -timeout 30s           # 设置超时
go test -parallel 4            # 最大并行测试数

# 竞态检测
go test -race                  # 启用竞态检测器

📌 概念:表驱动测试

2.1 标准表驱动模式

Go
func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {name: "正数相加", a: 1, b: 2, expected: 3},
        {name: "负数相加", a: -1, b: -2, expected: -3},
        {name: "零值", a: 0, b: 0, expected: 0},
        {name: "正负混合", a: 5, b: -3, expected: 2},
        {name: "大数", a: 1000000, b: 2000000, expected: 3000000},
    }

    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)
            }
        })
    }
}

2.2 带错误场景的表驱动测试

Go
func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        expected  float64
        expectErr bool
    }{
        {name: "正常除法", a: 10, b: 2, expected: 5.0, expectErr: false},
        {name: "除以零", a: 10, b: 0, expected: 0, expectErr: true},
        {name: "负数除法", a: -9, b: 3, expected: -3.0, expectErr: false},
        {name: "小数除法", a: 7, b: 2, expected: 3.5, expectErr: 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.Error("期望返回错误,但没有")
                }
                return
            }
            if err != nil {
                t.Fatalf("意外错误: %v", err)
            }
            if result != tt.expected {
                t.Errorf("Divide(%.1f, %.1f) = %.1f; want %.1f",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

📌 概念:子测试与并行测试

3.1 子测试(Subtest)

Go
func TestUserService(t *testing.T) {
    // 共享的 setup
    svc := NewUserService(NewMockRepo())

    t.Run("Create", func(t *testing.T) {
        user, err := svc.Create("Alice", "alice@example.com")
        if err != nil {
            t.Fatalf("创建失败: %v", err)
        }
        if user.Name != "Alice" {
            t.Errorf("名称不匹配: %s", user.Name)
        }
    })

    t.Run("Find", func(t *testing.T) {
        user, err := svc.FindByName("Alice")
        if err != nil {
            t.Fatalf("查找失败: %v", err)
        }
        if user == nil {
            t.Error("用户不应为 nil")
        }
    })

    t.Run("Delete", func(t *testing.T) {
        err := svc.Delete("Alice")
        if err != nil {
            t.Errorf("删除失败: %v", err)
        }
    })
}

3.2 并行测试

Go
func TestParallel(t *testing.T) {
    tests := []struct {
        name  string
        input string
    }{
        {"case1", "hello"},
        {"case2", "world"},
        {"case3", "foo"},
    }

    for _, tt := range tests {
        tt := tt // 捕获变量(Go 1.22 之前需要)
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // 标记为可并行
            // 模拟耗时操作
            time.Sleep(100 * time.Millisecond)
            result := process(tt.input)
            if result == "" {
                t.Error("空结果")
            }
        })
    }
}

3.3 TestMain

Go
// TestMain 控制整个测试包的生命周期
func TestMain(m *testing.M) {
    // 全局 setup
    fmt.Println("=== 初始化测试环境 ===")
    db := setupTestDB()

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

    // 全局 teardown
    fmt.Println("=== 清理测试环境 ===")
    db.Close()

    os.Exit(exitCode)
}

📌 概念:基准测试

4.1 编写基准测试

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

// 带设置的基准测试
func BenchmarkSortLargeSlice(b *testing.B) {
    // 准备测试数据(不计入基准时间)
    data := make([]int, 10000)
    for i := range data {
        data[i] = rand.Intn(10000)
    }

    b.ResetTimer() // 重置计时器

    for i := 0; i < b.N; i++ {
        // 每次迭代复制数据(避免排序后再排序影响结果)
        d := make([]int, len(data))
        copy(d, data)
        sort.Ints(d)
    }
}

// 报告自定义指标
func BenchmarkHTTPRequest(b *testing.B) {
    b.ReportAllocs() // 报告内存分配

    for i := 0; i < b.N; i++ {
        resp, _ := http.Get("http://localhost:8080/health")
        if resp != nil {
            resp.Body.Close()
        }
    }
}

// 子基准测试
func BenchmarkConcat(b *testing.B) {
    sizes := []int{10, 100, 1000, 10000}

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

            b.ResetTimer()
            for i := 0; i < b.N; i++ {
                _ = strings.Join(strs, ",")
            }
        })
    }
}

4.2 运行基准测试

Bash
# 运行基准测试
go test -bench=.                       # 所有基准测试
go test -bench=BenchmarkAdd            # 指定测试
go test -bench=. -benchtime=5s         # 指定时间
go test -bench=. -benchtime=10000x     # 指定迭代次数
go test -bench=. -benchmem             # 显示内存分配

# 比较基准结果(需要 benchstat 工具)
go test -bench=. -count=10 > old.txt
# 修改代码后
go test -bench=. -count=10 > new.txt
benchstat old.txt new.txt

📌 概念:测试覆盖率

5.1 生成覆盖率报告

Bash
# 查看覆盖率百分比
go test -cover

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

# HTML 可视化报告
go tool cover -html=coverage.out

# 按函数查看覆盖率
go tool cover -func=coverage.out

# 多包覆盖率
go test -coverprofile=coverage.out ./...

# 指定覆盖率模式
go test -covermode=atomic -coverprofile=coverage.out ./...
# count: 计数模式
# set: 是否执行
# atomic: 原子模式(并发安全,推荐)

📌 概念:模糊测试(Go 1.18+)

Go
func FuzzReverse(f *testing.F) {
    // 种子语料库
    f.Add("hello")
    f.Add("world")
    f.Add("")
    f.Add("a")

    f.Fuzz(func(t *testing.T, s string) {
        rev := Reverse(s)
        doubleRev := Reverse(rev)

        // 属性1:双重反转应该得到原字符串
        if s != doubleRev {
            t.Errorf("双重反转不等: %q → %q → %q", s, rev, doubleRev)
        }

        // 属性2:反转后长度应该相同
        if len(rev) != len(s) {
            t.Errorf("长度不同: %d vs %d", len(s), len(rev))
        }
    })
}
Bash
# 运行模糊测试
go test -fuzz=FuzzReverse              # 持续运行直到发现 bug 或 Ctrl+C
go test -fuzz=FuzzReverse -fuzztime=30s  # 运行 30 秒

💻 代码示例:实战测试技巧

示例1:HTTP Handler 测试

Go
func TestGetUserHandler(t *testing.T) {
    // 构建请求
    req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
    rec := httptest.NewRecorder()

    // 设置路由参数(如果用标准库)
    handler := NewUserHandler(mockService)
    handler.GetUser(rec, req)

    // 断言响应
    res := rec.Result()
    defer res.Body.Close()

    if res.StatusCode != http.StatusOK {
        t.Errorf("状态码 = %d; want %d", res.StatusCode, http.StatusOK)
    }

    var user User
    if err := json.NewDecoder(res.Body).Decode(&user); err != nil {
        t.Fatalf("解析响应失败: %v", err)
    }

    if user.Name != "Alice" {
        t.Errorf("用户名 = %s; want Alice", user.Name)
    }
}

// 测试 HTTP 服务器
func TestServerIntegration(t *testing.T) {
    srv := httptest.NewServer(setupRouter())
    defer srv.Close()

    resp, err := http.Get(srv.URL + "/health")
    if err != nil {
        t.Fatalf("请求失败: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != 200 {
        t.Errorf("健康检查失败: %d", resp.StatusCode)
    }
}

示例2:测试辅助函数

Go
// testutil.go
func assertEqual(t *testing.T, got, want any) {
    t.Helper() // 标记为辅助函数
    if !reflect.DeepEqual(got, want) {
        t.Errorf("got %v; want %v", got, want)
    }
}

func assertError(t *testing.T, err error) {
    t.Helper()
    if err == nil {
        t.Error("期望错误,但得到 nil")
    }
}

func assertNoError(t *testing.T, err error) {
    t.Helper()
    if err != nil {
        t.Fatalf("意外错误: %v", err)
    }
}

// 临时目录和文件
func createTempFile(t *testing.T, content string) string {
    t.Helper()
    dir := t.TempDir() // 测试结束自动清理
    path := filepath.Join(dir, "test.txt")
    if err := os.WriteFile(path, []byte(content), 0644); err != nil {
        t.Fatalf("创建临时文件失败: %v", err)
    }
    return path
}

示例3:Mock 接口

Go
// 使用接口实现简单 Mock
type MockDB struct {
    GetFunc    func(key string) (string, error)
    SetFunc    func(key, value string) error
    DeleteFunc func(key string) error
}

func (m *MockDB) Get(key string) (string, error) {
    if m.GetFunc != nil {
        return m.GetFunc(key)
    }
    return "", nil
}

func (m *MockDB) Set(key, value string) error {
    if m.SetFunc != nil {
        return m.SetFunc(key, value)
    }
    return nil
}

func (m *MockDB) Delete(key string) error {
    if m.DeleteFunc != nil {
        return m.DeleteFunc(key)
    }
    return nil
}

func TestCacheService(t *testing.T) {
    mock := &MockDB{
        GetFunc: func(key string) (string, error) {
            if key == "existing" {
                return "value", nil
            }
            return "", ErrNotFound
        },
    }

    svc := NewCacheService(mock)

    t.Run("缓存命中", func(t *testing.T) {
        val, err := svc.Get("existing")
        assertNoError(t, err)
        assertEqual(t, val, "value")
    })

    t.Run("缓存未命中", func(t *testing.T) {
        _, err := svc.Get("missing")
        assertError(t, err)
    })
}

✅ 最佳实践

1. 使用表驱动测试减少重复

2. 测试函数命名规范

Go
// 格式: Test<Function>_<Scenario>_<Expected>
func TestDivide_ByZero_ReturnsError(t *testing.T) { ... }
func TestUser_Create_WithValidInput(t *testing.T) { ... }

3. 用 t.Helper() 标记辅助函数

4. 使用 t.Cleanup 替代 defer 做清理

Go
func TestWithDB(t *testing.T) {
    db := setupDB(t)
    t.Cleanup(func() {
        db.Close() // 无论测试是否成功都会执行
    })
}

5. 测试覆盖率目标在 80% 以上,核心逻辑接近 100%


🎯 面试题

Q1: Go 的测试文件有什么命名约定?

A: 文件名以 _test.go 结尾,测试函数以 Test 开头(大写 T),参数为 *testing.T。基准测试以 Benchmark 开头,参数为 *testing.B。模糊测试以 Fuzz 开头,参数为 *testing.F_test.go 文件只在 go test 时编译,不会包含在正式构建中。

Q2: 表驱动测试的优势是什么?

A: 1) 减少重复代码——新增测试用例只需增加一行数据;2) 测试用例清晰可读——输入输出一目了然;3) 与子测试结合可独立运行和报告——go test -run TestAdd/正数相加;4) 易于维护——修改测试逻辑只需修改一处。

Q3: t.Error 和 t.Fatal 的区别?

A: t.Error / t.Errorf 报告错误后继续执行后续断言,适合检查多个条件。t.Fatal / t.Fatalf 报告错误后立即终止当前测试函数,适合前置条件检查(如初始化失败就没必要继续)。

Q4: 什么是模糊测试?怎么用?

A: 模糊测试(Go 1.18+)是自动化测试方法,通过随机生成输入来发现代码中的 bug(如 panic、边界条件、不一致行为)。用 f.Fuzz(func(t *testing.T, inputs...) {...}) 定义,f.Add(...) 提供种子语料。适合测试解析器、序列化函数等处理任意输入的函数。

Q5: 如何对 Go 的 HTTP handler 做单元测试?

A: 使用 net/http/httptest 包:1) httptest.NewRequest 创建请求;2) httptest.NewRecorder 创建响应记录器;3) 将二者传入 handler 函数;4) 检查 rec.Result() 的状态码和响应体。可用 httptest.NewServer 做集成测试。

Q6: -race 标志的作用和原理?

A: -race 启用竞态检测器(基于 ThreadSanitizer),在运行时检测并发数据竞争。它会对所有内存访问进行插桩,记录 goroutine 的 happens-before 关系,发现不安全的并发访问。开销约 2-10x 性能降低和 5-10x 内存增加,建议在 CI 中对所有测试启用。


📋 学习检查清单

  • 能编写基本单元测试和表驱动测试
  • 掌握子测试和并行测试
  • 理解 TestMain 的使用场景
  • 能编写和运行基准测试
  • 会生成和分析测试覆盖率报告
  • 了解模糊测试的基本用法
  • 能使用 httptest 测试 HTTP handler
  • 掌握 Mock 接口的实现模式

上一章: 包管理与模块 | 下一章: 反射与泛型