编程基础 0009_testing详解

Go testing 详解

目录

  1. testing 包基础
  2. 表格驱动测试
  3. 子测试 t.Run
  4. 基准测试 Benchmark
  5. 测试覆盖率
  6. TestMain
  7. httptest 包
  8. Mock 和接口测试技巧
  9. 模糊测试 Fuzz

1. testing 包基础

1.1 测试文件和函数命名规则

Go 测试遵循严格的命名约定:

  • 测试文件以 _test.go 结尾(如 user_test.go
  • 测试函数以 Test 开头,接一个大写字母开头的名称
  • 测试函数接受 *testing.T 参数
  • 测试文件和被测文件放在同一个包中
myproject/
├── math.go          # 被测代码
├── math_test.go     # 测试代码
├── user.go
└── user_test.go

1.2 第一个测试

// math.go
package mymath

// Add 返回两个整数的和
func Add(a, b int) int {
    return a + b
}

// Abs 返回绝对值
func Abs(n int) int {
    if n < 0 {
        return -n
    }
    return n
}
// math_test.go
package mymath

import "testing"

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

func TestAbs(t *testing.T) {
    result := Abs(-5)
    if result != 5 {
        t.Errorf("Abs(-5) = %d; 期望 5", result)
    }
}

运行测试:

# 运行当前包的所有测试
go test

# 显示详细输出
go test -v

# 运行匹配特定名称的测试
go test -run TestAdd

# 运行项目中所有包的测试
go test ./...

1.3 t.Error, t.Fatal, t.Log 的区别

package mymath

import "testing"

func TestErrorVsFatal(t *testing.T) {
    // t.Log 输出日志信息(仅在 -v 模式或测试失败时显示)
    t.Log("开始测试...")

    // t.Error / t.Errorf 标记测试失败,但继续执行后续代码
    result := Add(1, 1)
    if result != 2 {
        t.Errorf("Add(1,1) = %d; 期望 2", result)
    }
    t.Log("这行代码在 t.Error 后仍然会执行")

    // t.Fatal / t.Fatalf 标记测试失败,并立即停止当前测试函数
    if result == 0 {
        t.Fatal("结果为零,无法继续后续验证")
        // 下面的代码不会执行
    }
    t.Log("这行代码在 t.Fatal 未触发时才会执行")
}

// 何时用 Error,何时用 Fatal?
// - Error: 当后续的断言仍有意义时(想看到所有失败的断言)
// - Fatal: 当后续代码依赖于当前断言的结果(如 nil 检查后解引用)

func TestFatalForPrerequisite(t *testing.T) {
    // 典型用法:对 error/nil 检查用 Fatal
    result := Add(10, 20)

    // 假设这里是某个可能返回 nil 的操作
    if result == 0 {
        t.Fatal("前置条件不满足,终止测试")
    }

    // 后续操作依赖 result 不为 0
    t.Logf("结果: %d", result)
}

1.4 t.Helper

package mymath

import "testing"

// assertEqual 是一个测试辅助函数
// t.Helper() 让错误报告的行号指向调用者,而不是这个辅助函数内部
func assertEqual(t *testing.T, got, want int) {
    t.Helper() // 标记为辅助函数
    if got != want {
        t.Errorf("得到 %d; 期望 %d", got, want)
    }
}

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

func TestWithHelper(t *testing.T) {
    // 如果断言失败,错误报告会指向这一行,而不是 assertEqual 内部
    assertEqual(t, Add(1, 2), 3)
    assertEqual(t, Add(-1, 1), 0)
    assertEqual(t, Abs(-10), 10)
}

1.5 t.Cleanup

package mymath

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

func TestWithCleanup(t *testing.T) {
    // 创建临时文件
    f, err := os.CreateTemp("", "test-*")
    if err != nil {
        t.Fatal(err)
    }

    // t.Cleanup 注册清理函数,测试结束时自动执行
    // 类似 defer 但由测试框架管理,子测试结束时也会执行
    t.Cleanup(func() {
        f.Close()
        os.Remove(f.Name())
        fmt.Println("清理完毕:", f.Name())
    })

    // 在测试中使用临时文件
    _, err = f.WriteString("test data")
    if err != nil {
        t.Fatal(err)
    }
    t.Log("临时文件:", f.Name())
}

1.6 t.Skip 跳过测试

package mymath

import (
    "os"
    "runtime"
    "testing"
)

func TestOnlyOnLinux(t *testing.T) {
    if runtime.GOOS != "linux" {
        t.Skip("此测试仅在 Linux 上运行")
    }
    t.Log("在 Linux 上执行特定测试...")
}

func TestRequiresDatabase(t *testing.T) {
    dsn := os.Getenv("DATABASE_URL")
    if dsn == "" {
        t.Skip("跳过:未设置 DATABASE_URL 环境变量")
    }
    t.Logf("连接数据库: %s", dsn)
}

func TestSlow(t *testing.T) {
    if testing.Short() {
        t.Skip("跳过慢速测试(使用 -short 标志时)")
    }
    // 运行耗时较长的测试...
    t.Log("执行慢速测试...")
}

运行方式:

# 跳过慢速测试
go test -short

# 查看跳过原因
go test -v -short

2. 表格驱动测试

2.1 基本的表格驱动测试

表格驱动测试(Table-Driven Tests)是 Go 社区最推荐的测试模式:

package mymath

import "testing"

func TestAdd_TableDriven(t *testing.T) {
    // 定义测试用例表
    tests := []struct {
        name     string // 测试用例名称
        a, b     int    // 输入
        expected int    // 期望输出
    }{
        {"正数相加", 1, 2, 3},
        {"负数相加", -1, -2, -3},
        {"正负相加", -1, 1, 0},
        {"零值", 0, 0, 0},
        {"大数", 1000000, 2000000, 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; 期望 %d",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

2.2 包含错误检查的表格测试

package mymath

import (
    "errors"
    "fmt"
    "testing"
)

// Divide 整数除法
func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("除数不能为零")
    }
    return a / b, nil
}

func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        a, b      int
        want      int
        wantErr   bool   // 是否期望错误
        errMsg    string // 期望的错误消息(可选)
    }{
        {
            name: "正常除法",
            a: 10, b: 3,
            want: 3, wantErr: false,
        },
        {
            name: "整除",
            a: 10, b: 2,
            want: 5, wantErr: false,
        },
        {
            name: "除以零",
            a: 10, b: 0,
            want: 0, wantErr: true,
            errMsg: "除数不能为零",
        },
        {
            name: "负数除法",
            a: -10, b: 3,
            want: -3, wantErr: false,
        },
    }

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

            // 检查错误
            if tt.wantErr {
                if err == nil {
                    t.Fatal("期望错误,但没有返回错误")
                }
                if tt.errMsg != "" && err.Error() != tt.errMsg {
                    t.Errorf("错误消息 = %q; 期望 %q", err.Error(), tt.errMsg)
                }
                return // 有错误时不检查返回值
            }

            if err != nil {
                t.Fatalf("不期望错误,但得到: %v", err)
            }

            // 检查结果
            if got != tt.want {
                t.Errorf("Divide(%d, %d) = %d; 期望 %d",
                    tt.a, tt.b, got, tt.want)
            }
        })
    }
}

// 使用 map 作为测试用例集合(无序但可读性好)
func TestDivide_Map(t *testing.T) {
    tests := map[string]struct {
        a, b    int
        want    int
        wantErr bool
    }{
        "10/2": {10, 2, 5, false},
        "9/3":  {9, 3, 3, false},
        "1/0":  {1, 0, 0, true},
    }

    for name, tt := range tests {
        t.Run(name, func(t *testing.T) {
            got, err := Divide(tt.a, tt.b)
            if (err != nil) != tt.wantErr {
                t.Fatalf("Divide(%d, %d) error = %v, wantErr %v",
                    tt.a, tt.b, err, tt.wantErr)
            }
            if !tt.wantErr && got != tt.want {
                t.Errorf("got %d, want %d", got, tt.want)
            }
        })
    }
}

func init() {
    _ = fmt.Sprintf // avoid unused import
}

3. 子测试 t.Run

3.1 子测试基础

package mymath

import "testing"

func TestAbs_Subtests(t *testing.T) {
    // 子测试允许将相关测试组织在一起
    t.Run("正数", func(t *testing.T) {
        if Abs(5) != 5 {
            t.Error("正数的绝对值应该等于自身")
        }
    })

    t.Run("负数", func(t *testing.T) {
        if Abs(-5) != 5 {
            t.Error("负数的绝对值应该等于其相反数")
        }
    })

    t.Run("零", func(t *testing.T) {
        if Abs(0) != 0 {
            t.Error("零的绝对值应该等于零")
        }
    })
}

运行特定子测试:

# 运行 TestAbs_Subtests 下的 "负数" 子测试
go test -run TestAbs_Subtests/负数 -v

# 支持正则表达式
go test -run "TestAbs_Subtests/正" -v

3.2 嵌套子测试与共享 setup

package mymath

import (
    "testing"
)

// UserService 模拟服务
type UserService struct {
    users map[string]int // name -> age
}

func NewUserService() *UserService {
    return &UserService{
        users: map[string]int{
            "Alice": 30,
            "Bob":   25,
        },
    }
}

func (s *UserService) GetAge(name string) (int, bool) {
    age, ok := s.users[name]
    return age, ok
}

func (s *UserService) AddUser(name string, age int) {
    s.users[name] = age
}

func TestUserService(t *testing.T) {
    // 顶层 setup: 所有子测试共享
    svc := NewUserService()

    t.Run("GetAge", func(t *testing.T) {
        t.Run("存在的用户", func(t *testing.T) {
            age, ok := svc.GetAge("Alice")
            if !ok {
                t.Fatal("用户 Alice 应该存在")
            }
            if age != 30 {
                t.Errorf("Alice 的年龄 = %d; 期望 30", age)
            }
        })

        t.Run("不存在的用户", func(t *testing.T) {
            _, ok := svc.GetAge("Charlie")
            if ok {
                t.Error("用户 Charlie 不应该存在")
            }
        })
    })

    t.Run("AddUser", func(t *testing.T) {
        svc.AddUser("Dave", 35)
        age, ok := svc.GetAge("Dave")
        if !ok {
            t.Fatal("添加后用户 Dave 应该存在")
        }
        if age != 35 {
            t.Errorf("Dave 的年龄 = %d; 期望 35", age)
        }
    })
}

3.3 并行子测试

package mymath

import (
    "testing"
    "time"
)

// slowOperation 模拟耗时操作
func slowOperation(input int) int {
    time.Sleep(100 * time.Millisecond)
    return input * 2
}

func TestParallelSubtests(t *testing.T) {
    tests := []struct {
        name  string
        input int
        want  int
    }{
        {"double_1", 1, 2},
        {"double_2", 2, 4},
        {"double_5", 5, 10},
        {"double_10", 10, 20},
    }

    for _, tt := range tests {
        tt := tt // 重要!在 Go 1.22 之前需要创建循环变量的副本
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // 标记为可并行执行
            got := slowOperation(tt.input)
            if got != tt.want {
                t.Errorf("slowOperation(%d) = %d; want %d",
                    tt.input, got, tt.want)
            }
        })
    }
    // 所有并行子测试会在 TestParallelSubtests 返回前完成
}

注意:在 Go 1.22 之前,for 循环中的变量在每次迭代时共享同一地址。如果不使用 tt := tt 创建副本,并行子测试可能读到错误的值。Go 1.22+ 已修复此问题(循环变量按迭代创建)。


4. 基准测试 Benchmark

4.1 基准测试基础

package mymath

import "testing"

// 基准测试函数以 Benchmark 开头,参数是 *testing.B
func BenchmarkAdd(b *testing.B) {
    // b.N 由测试框架动态调整,直到结果稳定
    for i := 0; i < b.N; i++ {
        Add(100, 200)
    }
}

func BenchmarkAbs(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Abs(-42)
    }
}

运行基准测试:

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

# 运行特定基准测试
go test -bench=BenchmarkAdd

# 运行多次以获得更稳定的结果
go test -bench=. -count=5

# 同时显示内存分配信息
go test -bench=. -benchmem

# 指定运行时间(默认1秒)
go test -bench=. -benchtime=3s

输出示例:

BenchmarkAdd-8     1000000000     0.2950 ns/op
BenchmarkAbs-8     1000000000     0.5100 ns/op
  • -8 表示使用 8 个 CPU 核心
  • 1000000000 是 b.N 的值(运行次数)
  • 0.2950 ns/op 是每次操作的耗时

4.2 b.ResetTimer 和 b.StopTimer

package mymath

import (
    "testing"
    "time"
)

// 场景:基准测试需要准备数据,但准备时间不应计入基准

func BenchmarkWithSetup(b *testing.B) {
    // 准备阶段(耗时的初始化)
    data := make([]int, 10000)
    for i := range data {
        data[i] = i
    }
    time.Sleep(10 * time.Millisecond) // 模拟耗时初始化

    // 重置计时器,排除准备阶段的时间
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        // 只测量这个操作的性能
        sum := 0
        for _, v := range data {
            sum += v
        }
    }
}

func BenchmarkWithPause(b *testing.B) {
    for i := 0; i < b.N; i++ {
        // 暂停计时:准备每次迭代的输入
        b.StopTimer()
        data := make([]int, 100)
        for j := range data {
            data[j] = j
        }
        b.StartTimer()

        // 只测量这个操作
        sum := 0
        for _, v := range data {
            sum += v
        }
        _ = sum
    }
}

4.3 b.ReportAllocs 和内存基准

package mymath

import (
    "fmt"
    "strings"
    "testing"
)

// 比较字符串拼接的不同方式

func concatWithPlus(strs []string) string {
    result := ""
    for _, s := range strs {
        result += s
    }
    return result
}

func concatWithBuilder(strs []string) string {
    var b strings.Builder
    for _, s := range strs {
        b.WriteString(s)
    }
    return b.String()
}

func concatWithJoin(strs []string) string {
    return strings.Join(strs, "")
}

func BenchmarkConcatPlus(b *testing.B) {
    strs := make([]string, 100)
    for i := range strs {
        strs[i] = fmt.Sprintf("item%d", i)
    }

    b.ReportAllocs() // 报告内存分配
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        concatWithPlus(strs)
    }
}

func BenchmarkConcatBuilder(b *testing.B) {
    strs := make([]string, 100)
    for i := range strs {
        strs[i] = fmt.Sprintf("item%d", i)
    }

    b.ReportAllocs()
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        concatWithBuilder(strs)
    }
}

func BenchmarkConcatJoin(b *testing.B) {
    strs := make([]string, 100)
    for i := range strs {
        strs[i] = fmt.Sprintf("item%d", i)
    }

    b.ReportAllocs()
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        concatWithJoin(strs)
    }
}

运行结果示例(go test -bench=BenchmarkConcat -benchmem):

BenchmarkConcatPlus-8       5000    300000 ns/op   530000 B/op   99 allocs/op
BenchmarkConcatBuilder-8  200000      8000 ns/op     2048 B/op    8 allocs/op
BenchmarkConcatJoin-8     200000      7500 ns/op     1024 B/op    1 allocs/op

可以清楚看到 + 拼接的内存分配远高于 BuilderJoin

4.4 子基准测试

package mymath

import (
    "fmt"
    "testing"
)

func BenchmarkAbs_Sizes(b *testing.B) {
    inputs := []int{1, 100, -1, -100, 0}

    for _, input := range inputs {
        b.Run(fmt.Sprintf("input=%d", input), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                Abs(input)
            }
        })
    }
}
go test -bench="BenchmarkAbs_Sizes" -v
# BenchmarkAbs_Sizes/input=1-8     1000000000    0.30 ns/op
# BenchmarkAbs_Sizes/input=100-8   1000000000    0.30 ns/op
# BenchmarkAbs_Sizes/input=-1-8    1000000000    0.52 ns/op
# ...

5. 测试覆盖率

5.1 基本覆盖率

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

# 输出示例:
# PASS
# coverage: 85.7% of statements
# ok    mypackage    0.003s

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

# 在终端查看每个函数的覆盖率
go tool cover -func=coverage.out
# 输出示例:
# mypackage/math.go:3:   Add      100.0%
# mypackage/math.go:8:   Abs       80.0%
# mypackage/math.go:15:  Divide   100.0%
# total:                 (statements) 93.3%

# 在浏览器中查看可视化覆盖率报告(绿色=已覆盖,红色=未覆盖)
go tool cover -html=coverage.out

5.2 覆盖率模式

# 默认模式:set(行是否被执行)
go test -coverprofile=coverage.out -covermode=set

# count 模式:每行执行了几次
go test -coverprofile=coverage.out -covermode=count

# atomic 模式:count 的线程安全版本,适合 -race
go test -coverprofile=coverage.out -covermode=atomic

5.3 多包覆盖率

# 计算所有包的覆盖率
go test -coverprofile=coverage.out ./...

# 包含所有包(即使没有测试文件的包也统计)
go test -coverpkg=./... -coverprofile=coverage.out ./...

5.4 在 CI 中使用覆盖率

#!/bin/bash
# ci_test.sh - CI 中的测试脚本

# 运行测试并生成覆盖率报告
go test -coverprofile=coverage.out -covermode=atomic ./...

# 提取总覆盖率
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')

echo "总覆盖率: ${COVERAGE}%"

# 检查覆盖率是否达到最低要求(如 80%)
MIN_COVERAGE=80.0
if [ "$(echo "$COVERAGE < $MIN_COVERAGE" | bc)" -eq 1 ]; then
    echo "覆盖率 ${COVERAGE}% 低于最低要求 ${MIN_COVERAGE}%"
    exit 1
fi

6. TestMain

6.1 TestMain 基础

TestMain 可以控制测试的整体生命周期,在所有测试执行前后做 setup 和 teardown。

package mymath

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

// TestMain 每个包最多只能有一个
// 它在所有测试之前运行,控制测试流程
func TestMain(m *testing.M) {
    // ===== Setup: 在所有测试之前执行 =====
    fmt.Println(">>> 全局 Setup: 初始化测试环境")

    // 例如:创建测试数据库、启动测试服务器、加载测试数据等

    // ===== 运行所有测试 =====
    // m.Run() 运行所有 Test* 函数,返回退出码
    exitCode := m.Run()

    // ===== Teardown: 在所有测试之后执行 =====
    fmt.Println(">>> 全局 Teardown: 清理测试环境")

    // 例如:删除测试数据库、关闭连接等

    // 必须调用 os.Exit,否则测试结果不会正确报告
    os.Exit(exitCode)
}

func TestExample1(t *testing.T) {
    t.Log("运行测试1")
}

func TestExample2(t *testing.T) {
    t.Log("运行测试2")
}

输出:

>>> 全局 Setup: 初始化测试环境
=== RUN   TestExample1
    运行测试1
--- PASS: TestExample1
=== RUN   TestExample2
    运行测试2
--- PASS: TestExample2
>>> 全局 Teardown: 清理测试环境
PASS

6.2 实际应用:数据库测试

package db_test

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

    _ "github.com/mattn/go-sqlite3" // SQLite 驱动
)

var testDB *sql.DB

func TestMain(m *testing.M) {
    // Setup: 创建测试数据库
    var err error
    testDB, err = sql.Open("sqlite3", ":memory:")
    if err != nil {
        fmt.Fprintf(os.Stderr, "无法创建测试数据库: %v\n", err)
        os.Exit(1)
    }

    // 创建表结构
    _, err = testDB.Exec(`
        CREATE TABLE users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL,
            email TEXT UNIQUE NOT NULL
        )
    `)
    if err != nil {
        fmt.Fprintf(os.Stderr, "无法创建表: %v\n", err)
        os.Exit(1)
    }

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

    // Teardown: 关闭数据库
    testDB.Close()

    os.Exit(exitCode)
}

func TestInsertUser(t *testing.T) {
    // 每个测试前清理数据(或使用事务回滚)
    t.Cleanup(func() {
        testDB.Exec("DELETE FROM users")
    })

    _, err := testDB.Exec(
        "INSERT INTO users (name, email) VALUES (?, ?)",
        "Alice", "alice@example.com",
    )
    if err != nil {
        t.Fatalf("插入用户失败: %v", err)
    }

    var count int
    err = testDB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
    if err != nil {
        t.Fatalf("查询失败: %v", err)
    }
    if count != 1 {
        t.Errorf("用户数量 = %d; 期望 1", count)
    }
}

func TestUniqueEmail(t *testing.T) {
    t.Cleanup(func() {
        testDB.Exec("DELETE FROM users")
    })

    testDB.Exec("INSERT INTO users (name, email) VALUES (?, ?)",
        "Alice", "alice@example.com")

    // 插入重复邮箱应该失败
    _, err := testDB.Exec("INSERT INTO users (name, email) VALUES (?, ?)",
        "Bob", "alice@example.com")

    if err == nil {
        t.Error("期望唯一约束错误,但没有返回错误")
    }
}

7. httptest 包

7.1 测试 HTTP Handler

package api

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

// ========== 被测代码 ==========

// HealthHandler 健康检查
func HealthHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{
        "status": "ok",
    })
}

// GreetHandler 问候接口
func GreetHandler(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    if name == "" {
        http.Error(w, "缺少 name 参数", http.StatusBadRequest)
        return
    }
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    fmt.Fprintf(w, "你好, %s!", name)
}

// CreateUserHandler 创建用户
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "只接受 POST 请求", http.StatusMethodNotAllowed)
        return
    }

    var user struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }

    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        http.Error(w, "无效的 JSON", http.StatusBadRequest)
        return
    }

    if user.Name == "" || user.Email == "" {
        http.Error(w, "name 和 email 不能为空", http.StatusBadRequest)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(map[string]interface{}{
        "id":    1,
        "name":  user.Name,
        "email": user.Email,
    })
}

// ========== 测试代码 ==========

func TestHealthHandler(t *testing.T) {
    // 创建请求
    req := httptest.NewRequest(http.MethodGet, "/health", nil)

    // 创建 ResponseRecorder(模拟的 http.ResponseWriter)
    rr := httptest.NewRecorder()

    // 调用 handler
    HealthHandler(rr, req)

    // 检查状态码
    if rr.Code != http.StatusOK {
        t.Errorf("状态码 = %d; 期望 %d", rr.Code, http.StatusOK)
    }

    // 检查 Content-Type
    contentType := rr.Header().Get("Content-Type")
    if contentType != "application/json" {
        t.Errorf("Content-Type = %s; 期望 application/json", contentType)
    }

    // 检查响应体
    var body map[string]string
    if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
        t.Fatalf("解析响应体失败: %v", err)
    }
    if body["status"] != "ok" {
        t.Errorf("status = %s; 期望 ok", body["status"])
    }
}

func TestGreetHandler(t *testing.T) {
    tests := []struct {
        name       string
        queryName  string
        wantCode   int
        wantBody   string
    }{
        {
            name:      "正常请求",
            queryName: "Alice",
            wantCode:  http.StatusOK,
            wantBody:  "你好, Alice!",
        },
        {
            name:      "缺少参数",
            queryName: "",
            wantCode:  http.StatusBadRequest,
            wantBody:  "缺少 name 参数\n",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            url := "/greet"
            if tt.queryName != "" {
                url += "?name=" + tt.queryName
            }

            req := httptest.NewRequest(http.MethodGet, url, nil)
            rr := httptest.NewRecorder()

            GreetHandler(rr, req)

            if rr.Code != tt.wantCode {
                t.Errorf("状态码 = %d; 期望 %d", rr.Code, tt.wantCode)
            }
            if rr.Body.String() != tt.wantBody {
                t.Errorf("响应体 = %q; 期望 %q", rr.Body.String(), tt.wantBody)
            }
        })
    }
}

func TestCreateUserHandler(t *testing.T) {
    tests := []struct {
        name     string
        method   string
        body     string
        wantCode int
    }{
        {
            name:     "成功创建",
            method:   http.MethodPost,
            body:     `{"name":"Alice","email":"alice@example.com"}`,
            wantCode: http.StatusCreated,
        },
        {
            name:     "错误的方法",
            method:   http.MethodGet,
            body:     "",
            wantCode: http.StatusMethodNotAllowed,
        },
        {
            name:     "无效JSON",
            method:   http.MethodPost,
            body:     "not json",
            wantCode: http.StatusBadRequest,
        },
        {
            name:     "缺少字段",
            method:   http.MethodPost,
            body:     `{"name":"Alice"}`,
            wantCode: http.StatusBadRequest,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            var body *strings.Reader
            if tt.body != "" {
                body = strings.NewReader(tt.body)
            } else {
                body = strings.NewReader("")
            }

            req := httptest.NewRequest(tt.method, "/users", body)
            req.Header.Set("Content-Type", "application/json")
            rr := httptest.NewRecorder()

            CreateUserHandler(rr, req)

            if rr.Code != tt.wantCode {
                t.Errorf("状态码 = %d; 期望 %d\n响应: %s",
                    rr.Code, tt.wantCode, rr.Body.String())
            }
        })
    }
}

7.2 httptest.NewServer 测试完整的 HTTP 流程

package api

import (
    "encoding/json"
    "io"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestWithTestServer(t *testing.T) {
    // 创建一个真正监听端口的测试服务器
    mux := http.NewServeMux()
    mux.HandleFunc("/health", HealthHandler)
    mux.HandleFunc("/greet", GreetHandler)

    server := httptest.NewServer(mux)
    defer server.Close() // 测试结束后关闭服务器

    t.Logf("测试服务器地址: %s", server.URL)

    // 像真正的客户端一样发送请求
    t.Run("health", func(t *testing.T) {
        resp, err := http.Get(server.URL + "/health")
        if err != nil {
            t.Fatalf("请求失败: %v", err)
        }
        defer resp.Body.Close()

        if resp.StatusCode != http.StatusOK {
            t.Errorf("状态码 = %d; 期望 200", resp.StatusCode)
        }

        var body map[string]string
        json.NewDecoder(resp.Body).Decode(&body)
        if body["status"] != "ok" {
            t.Errorf("status = %s; 期望 ok", body["status"])
        }
    })

    t.Run("greet", func(t *testing.T) {
        resp, err := http.Get(server.URL + "/greet?name=World")
        if err != nil {
            t.Fatalf("请求失败: %v", err)
        }
        defer resp.Body.Close()

        bodyBytes, _ := io.ReadAll(resp.Body)
        if string(bodyBytes) != "你好, World!" {
            t.Errorf("响应 = %q; 期望 %q", string(bodyBytes), "你好, World!")
        }
    })
}

// 测试 TLS 服务器
func TestWithTLSServer(t *testing.T) {
    server := httptest.NewTLSServer(http.HandlerFunc(HealthHandler))
    defer server.Close()

    // server.Client() 返回配置好 TLS 证书的 http.Client
    client := server.Client()

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

    if resp.StatusCode != http.StatusOK {
        t.Errorf("状态码 = %d; 期望 200", resp.StatusCode)
    }
}

8. Mock 和接口测试技巧

8.1 使用接口实现 Mock

Go 推荐通过接口来实现依赖注入和 Mock,而不是使用复杂的 Mock 框架。

package service

import (
    "errors"
    "testing"
)

// ========== 定义接口 ==========

// UserRepository 用户数据访问接口
type UserRepository interface {
    GetByID(id int) (*User, error)
    Save(user *User) error
    Delete(id int) error
}

// EmailSender 邮件发送接口
type EmailSender interface {
    Send(to, subject, body string) error
}

type User struct {
    ID    int
    Name  string
    Email string
}

// ========== 被测服务 ==========

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

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

func (s *UserService) GetUser(id int) (*User, error) {
    return s.repo.GetByID(id)
}

func (s *UserService) CreateUser(name, email string) (*User, error) {
    user := &User{Name: name, Email: email}
    if err := s.repo.Save(user); err != nil {
        return nil, err
    }

    // 发送欢迎邮件
    err := s.email.Send(email, "欢迎", "欢迎加入!")
    if err != nil {
        // 邮件发送失败不影响用户创建
        // 实际项目中可能记录日志或放入队列
    }

    return user, nil
}

// ========== Mock 实现 ==========

// MockUserRepo 模拟用户仓库
type MockUserRepo struct {
    users    map[int]*User
    saveErr  error // 可以注入错误
    saveCalled bool
}

func NewMockUserRepo() *MockUserRepo {
    return &MockUserRepo{
        users: make(map[int]*User),
    }
}

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

func (m *MockUserRepo) Save(user *User) error {
    m.saveCalled = true
    if m.saveErr != nil {
        return m.saveErr
    }
    user.ID = len(m.users) + 1
    m.users[user.ID] = user
    return nil
}

func (m *MockUserRepo) Delete(id int) error {
    delete(m.users, id)
    return nil
}

// MockEmailSender 模拟邮件发送
type MockEmailSender struct {
    sentEmails []struct {
        To, Subject, Body string
    }
    sendErr error
}

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

// ========== 测试 ==========

func TestUserService_GetUser(t *testing.T) {
    repo := NewMockUserRepo()
    repo.users[1] = &User{ID: 1, Name: "Alice", Email: "alice@test.com"}

    svc := NewUserService(repo, &MockEmailSender{})

    t.Run("用户存在", func(t *testing.T) {
        user, err := svc.GetUser(1)
        if err != nil {
            t.Fatalf("不期望错误: %v", err)
        }
        if user.Name != "Alice" {
            t.Errorf("Name = %s; 期望 Alice", user.Name)
        }
    })

    t.Run("用户不存在", func(t *testing.T) {
        _, err := svc.GetUser(999)
        if err == nil {
            t.Fatal("期望错误,但没有返回")
        }
    })
}

func TestUserService_CreateUser(t *testing.T) {
    t.Run("成功创建并发送邮件", func(t *testing.T) {
        repo := NewMockUserRepo()
        emailer := &MockEmailSender{}
        svc := NewUserService(repo, emailer)

        user, err := svc.CreateUser("Bob", "bob@test.com")
        if err != nil {
            t.Fatalf("创建失败: %v", err)
        }
        if user.ID == 0 {
            t.Error("用户ID不应为0")
        }
        if !repo.saveCalled {
            t.Error("Save 应该被调用")
        }
        if len(emailer.sentEmails) != 1 {
            t.Errorf("发送了 %d 封邮件; 期望 1", len(emailer.sentEmails))
        }
        if emailer.sentEmails[0].To != "bob@test.com" {
            t.Errorf("邮件发送给 %s; 期望 bob@test.com",
                emailer.sentEmails[0].To)
        }
    })

    t.Run("保存失败", func(t *testing.T) {
        repo := NewMockUserRepo()
        repo.saveErr = errors.New("数据库错误")
        svc := NewUserService(repo, &MockEmailSender{})

        _, err := svc.CreateUser("Bob", "bob@test.com")
        if err == nil {
            t.Fatal("期望错误")
        }
    })

    t.Run("邮件失败不影响创建", func(t *testing.T) {
        repo := NewMockUserRepo()
        emailer := &MockEmailSender{sendErr: errors.New("SMTP错误")}
        svc := NewUserService(repo, emailer)

        user, err := svc.CreateUser("Charlie", "charlie@test.com")
        if err != nil {
            t.Fatalf("用户创建不应失败: %v", err)
        }
        if user.Name != "Charlie" {
            t.Errorf("Name = %s; 期望 Charlie", user.Name)
        }
    })
}

8.2 函数类型作为轻量 Mock

package service

import (
    "fmt"
    "testing"
)

// Fetcher 使用函数类型定义接口(更轻量)
type Fetcher func(url string) ([]byte, error)

// CachedFetcher 带缓存的获取器
type CachedFetcher struct {
    fetch Fetcher
    cache map[string][]byte
}

func NewCachedFetcher(fetch Fetcher) *CachedFetcher {
    return &CachedFetcher{
        fetch: fetch,
        cache: make(map[string][]byte),
    }
}

func (cf *CachedFetcher) Get(url string) ([]byte, error) {
    if data, ok := cf.cache[url]; ok {
        return data, nil // 缓存命中
    }

    data, err := cf.fetch(url)
    if err != nil {
        return nil, fmt.Errorf("获取 %s: %w", url, err)
    }

    cf.cache[url] = data
    return data, nil
}

func TestCachedFetcher(t *testing.T) {
    callCount := 0

    // 直接用闭包作为 Mock
    mockFetch := Fetcher(func(url string) ([]byte, error) {
        callCount++
        return []byte("response from " + url), nil
    })

    cf := NewCachedFetcher(mockFetch)

    // 第一次调用:应该调用 fetch
    data, err := cf.Get("https://example.com")
    if err != nil {
        t.Fatal(err)
    }
    if string(data) != "response from https://example.com" {
        t.Errorf("响应 = %s", string(data))
    }
    if callCount != 1 {
        t.Errorf("fetch 被调用 %d 次; 期望 1", callCount)
    }

    // 第二次调用相同 URL:应该命中缓存,不调用 fetch
    _, err = cf.Get("https://example.com")
    if err != nil {
        t.Fatal(err)
    }
    if callCount != 1 {
        t.Errorf("fetch 被调用 %d 次; 期望仍为 1(缓存命中)", callCount)
    }

    // 不同 URL:应该再次调用 fetch
    _, err = cf.Get("https://other.com")
    if err != nil {
        t.Fatal(err)
    }
    if callCount != 2 {
        t.Errorf("fetch 被调用 %d 次; 期望 2", callCount)
    }
}

8.3 接口设计的测试原则

package service

import "testing"

// 测试技巧:在消费者包中定义小接口
// 不要在提供者包中定义大接口

// 差的做法:定义一个大而全的接口
// type BigRepository interface {
//     GetByID(id int) (*User, error)
//     GetByEmail(email string) (*User, error)
//     GetAll() ([]*User, error)
//     Save(user *User) error
//     Update(user *User) error
//     Delete(id int) error
//     Count() (int, error)
//     ... 更多方法
// }

// 好的做法:按需定义小接口(接口隔离原则)
type UserGetter interface {
    GetByID(id int) (*User, error)
}

type UserSaver interface {
    Save(user *User) error
}

// 函数只依赖它真正需要的方法
func getUserName(getter UserGetter, id int) (string, error) {
    user, err := getter.GetByID(id)
    if err != nil {
        return "", err
    }
    return user.Name, nil
}

// 测试时只需要 mock 用到的方法
type simpleGetter struct {
    user *User
    err  error
}

func (g *simpleGetter) GetByID(id int) (*User, error) {
    return g.user, g.err
}

func TestGetUserName(t *testing.T) {
    // Mock 非常简单,只需实现一个方法
    getter := &simpleGetter{
        user: &User{ID: 1, Name: "Alice"},
    }

    name, err := getUserName(getter, 1)
    if err != nil {
        t.Fatal(err)
    }
    if name != "Alice" {
        t.Errorf("name = %s; want Alice", name)
    }
}

9. 模糊测试 Fuzz

9.1 模糊测试基础(Go 1.18+)

模糊测试(Fuzz Testing)自动生成大量随机输入来发现边界情况和潜在的 bug。

package mymath

import (
    "testing"
    "unicode/utf8"
)

// 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 模糊测试函数以 Fuzz 开头
func FuzzReverse(f *testing.F) {
    // 添加种子语料库(seed corpus)
    // 模糊测试引擎会基于这些种子生成更多测试输入
    f.Add("hello")
    f.Add("世界")
    f.Add("")
    f.Add("a")
    f.Add("Hello, 世界! 🌍")

    // f.Fuzz 接收的函数参数类型必须与 f.Add 的参数类型一致
    f.Fuzz(func(t *testing.T, original string) {
        // 对每个自动生成的输入执行测试

        // 属性1: 反转两次应该得到原始字符串
        reversed := Reverse(original)
        doubleReversed := Reverse(reversed)
        if original != doubleReversed {
            t.Errorf("反转两次不等于原始值:\n原始: %q\n反转: %q\n双反: %q",
                original, reversed, doubleReversed)
        }

        // 属性2: 反转后的字符串长度应该与原始相同
        if utf8.RuneCountInString(original) != utf8.RuneCountInString(reversed) {
            t.Errorf("长度不一致:\n原始: %q (len=%d)\n反转: %q (len=%d)",
                original, utf8.RuneCountInString(original),
                reversed, utf8.RuneCountInString(reversed))
        }

        // 属性3: 反转后的字符串应该是有效的 UTF-8
        if !utf8.ValidString(reversed) {
            t.Errorf("反转后不是有效的 UTF-8: %q", reversed)
        }
    })
}

运行模糊测试:

# 仅运行种子语料库(不做模糊测试)
go test -run FuzzReverse

# 运行模糊测试(持续运行直到找到 bug 或被中断)
go test -fuzz FuzzReverse

# 限制模糊测试运行时间
go test -fuzz FuzzReverse -fuzztime 30s

# 限制模糊测试迭代次数
go test -fuzz FuzzReverse -fuzztime 1000x

9.2 支持的参数类型

模糊测试的 f.Addf.Fuzz 支持以下类型:

package mymath

import "testing"

// 所有支持的参数类型
func FuzzAllTypes(f *testing.F) {
    f.Add(
        "string",           // string
        []byte("bytes"),    // []byte
        int(42),            // int
        int8(8),            // int8
        int16(16),          // int16
        int32(32),          // int32
        int64(64),          // int64
        uint(42),           // uint
        uint8(8),           // uint8
        uint16(16),         // uint16
        uint32(32),         // uint32
        uint64(64),         // uint64
        float32(3.14),      // float32
        float64(2.718),     // float64
        rune('A'),          // rune (= int32)
        byte(0xFF),         // byte (= uint8)
        true,               // bool
    )

    f.Fuzz(func(t *testing.T,
        s string, b []byte,
        i int, i8 int8, i16 int16, i32 int32, i64 int64,
        u uint, u8 uint8, u16 uint16, u32 uint32, u64 uint64,
        f32 float32, f64 float64,
        r rune, by byte, bl bool,
    ) {
        // 使用自动生成的各类型值进行测试
        _ = s
        _ = b
    })
}

9.3 实际应用:测试 JSON 解析

package mymath

import (
    "encoding/json"
    "testing"
)

// Config 应用配置
type Config struct {
    Name    string `json:"name"`
    Port    int    `json:"port"`
    Debug   bool   `json:"debug"`
}

// ParseConfig 解析配置 JSON
func ParseConfig(data []byte) (*Config, error) {
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, err
    }
    return &cfg, nil
}

func FuzzParseConfig(f *testing.F) {
    // 种子:有效的 JSON
    f.Add([]byte(`{"name":"app","port":8080,"debug":true}`))
    f.Add([]byte(`{}`))
    f.Add([]byte(`{"name":""}`))

    // 种子:无效的输入
    f.Add([]byte(`invalid json`))
    f.Add([]byte(``))
    f.Add([]byte(`null`))

    f.Fuzz(func(t *testing.T, data []byte) {
        cfg, err := ParseConfig(data)
        if err != nil {
            // 解析失败是正常的(对于随机输入)
            // 关键是不能 panic
            return
        }

        // 如果解析成功,重新序列化应该不 panic
        reEncoded, err := json.Marshal(cfg)
        if err != nil {
            t.Errorf("重新编码失败: %v", err)
        }

        // 重新解析应该成功
        var cfg2 Config
        if err := json.Unmarshal(reEncoded, &cfg2); err != nil {
            t.Errorf("重新解析失败: %v", err)
        }

        // 属性:name 字段应该保持一致
        if cfg.Name != cfg2.Name {
            t.Errorf("Name 不一致: %q vs %q", cfg.Name, cfg2.Name)
        }
    })
}

9.4 模糊测试发现 bug 后的工作流

# 1. 运行模糊测试
go test -fuzz FuzzReverse -fuzztime 1m

# 如果发现 bug,测试框架会将导致失败的输入保存到
# testdata/fuzz/FuzzReverse/ 目录下

# 2. 查看失败的输入
cat testdata/fuzz/FuzzReverse/xxxxxxxxx

# 3. 修复 bug 后,再次运行测试
# 种子语料库中保存的失败用例会自动成为回归测试
go test -run FuzzReverse

# 4. 继续模糊测试确认修复
go test -fuzz FuzzReverse -fuzztime 1m

9.5 模糊测试实用示例:URL 解析

package mymath

import (
    "net/url"
    "testing"
)

func FuzzURLParse(f *testing.F) {
    f.Add("https://example.com/path?query=value#fragment")
    f.Add("http://localhost:8080")
    f.Add("ftp://user:pass@host/dir")
    f.Add("/relative/path")
    f.Add("")

    f.Fuzz(func(t *testing.T, rawURL string) {
        // 解析 URL
        parsed, err := url.Parse(rawURL)
        if err != nil {
            return // 无效 URL 是可接受的
        }

        // 属性:重新编码后再解析应该得到相同结果
        reEncoded := parsed.String()
        reParsed, err := url.Parse(reEncoded)
        if err != nil {
            t.Errorf("重新编码的 URL 无法解析: %q -> %q -> error: %v",
                rawURL, reEncoded, err)
            return
        }

        // 关键字段应该一致
        if parsed.Scheme != reParsed.Scheme {
            t.Errorf("Scheme 不一致: %q vs %q", parsed.Scheme, reParsed.Scheme)
        }
        if parsed.Host != reParsed.Host {
            t.Errorf("Host 不一致: %q vs %q", parsed.Host, reParsed.Host)
        }
        if parsed.Path != reParsed.Path {
            t.Errorf("Path 不一致: %q vs %q", parsed.Path, reParsed.Path)
        }
    })
}

总结

功能 核心用法 运行命令
单元测试 func TestXxx(t *testing.T) go test -v
表格驱动 tests := []struct{...} + t.Run go test -run TestXxx
子测试 t.Run("name", func(t *testing.T){...}) go test -run TestXxx/sub
并行测试 t.Parallel() go test -parallel 4
基准测试 func BenchmarkXxx(b *testing.B) go test -bench=.
内存分析 b.ReportAllocs() go test -bench=. -benchmem
覆盖率 -cover / -coverprofile go test -cover
TestMain func TestMain(m *testing.M) 自动调用
HTTP 测试 httptest.NewRequest + httptest.NewRecorder go test -v
测试服务器 httptest.NewServer(handler) go test -v
Mock 定义接口 + 实现 Mock 结构体 go test -v
模糊测试 func FuzzXxx(f *testing.F) go test -fuzz FuzzXxx

常用命令速查

# 运行所有测试
go test ./...

# 详细输出
go test -v ./...

# 跳过慢测试
go test -short ./...

# 检测数据竞争
go test -race ./...

# 覆盖率报告
go test -coverprofile=c.out ./... && go tool cover -html=c.out

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

# 模糊测试(30秒)
go test -fuzz=FuzzXxx -fuzztime=30s

# 运行特定测试
go test -run "TestUser/创建" -v

主题测试文章,只做测试使用。发布者:Walker,转转请注明出处:https://walker-learn.xyz/archives/6723

(0)
Walker的头像Walker
上一篇 14小时前
下一篇 18小时前

相关推荐

  • Go资深工程师讲解(慕课) 002

    go(二) string 字符串 package main import ( "fmt" "unicode/utf8" ) func main() { s := "Yes我爱Go语言" fmt.Println(len(s)) for _, b := range []byte(s) { fmt.Pri…

  • Go工程师体系课 017

    限流、熔断与降级入门(含 Sentinel 实战) 结合课件第 3 章(3-1 ~ 3-9)的视频要点,整理一套面向初学者的服务保护指南,帮助理解“为什么需要限流、熔断和降级”,以及如何用 Sentinel 快速上手。 学习路线速览 3-1 理解服务雪崩与限流、熔断、降级的背景 3-2 Sentinel 与 Hystrix 对比,明确技术选型 3-3 Sen…

    后端开发 6分钟前
    000
  • 编程基础 0002_名库讲解

    名库讲解 goconfig go 语言针对 windows 下常见的 ini 格式的配置文件解析器,该解析器在涵盖了所有 ini 文件操作的基础上,又针对 go 语言实际开发过程中遇到的一些需求进行了扩展。该解析器最大的优势在于对注释的极佳支持,除此之外,支持多个配置文件覆盖加载也是非常特别但好用的功能。 提供与 windows api 一模一样的操作 支持…

    17小时前
    400
  • Go工程师体系课 011

    查询的倒排索引 1. 什么是倒排索引? 倒排索引(Inverted Index)是一种数据结构,用于快速查找包含特定词汇的文档。它是搜索引擎的核心技术之一。 1.1 基本概念 正排索引:文档 ID → 文档内容(词列表) 倒排索引:词 → 包含该词的文档 ID 列表 1.2 为什么叫"倒排"? 倒排索引将传统的"文档包含哪些词"的关系倒转为"词出现在哪些文档…

    后端开发 6小时前
    100
  • 编程基础 0012_Go_Web与网络编程精华

    Go Web 与网络编程精华 知识来源:- 《Building Web Apps with Go》- 《Go API 编程》- 《Go Web 编程》(Go Web Programming, Sau Sheong Chang)- 《Go 网络编程》(Network Programming with Go)- 《Mastering Go Web Service…

    后端开发 22小时前
    400
简体中文 繁体中文 English