导航菜单

单元测试

单元测试(Unit Testing)

单元测试是对程序中最小可测试单元(通常是函数或方法)进行验证的过程。Go 语言在标准库中提供了 testing 包,内置了测试框架,并约定测试文件以 _test.go 结尾,测试函数以 Test 开头。go test 命令自动发现并执行所有测试。

testing 包基础

测试文件命名约定

规则说明示例
文件名_test.go 结尾math_test.go
函数名Test 开头func TestAdd(t *testing.T)
函数签名接收 *testing.T 参数,无返回值func TestXxx(t *testing.T)
包名与被测包相同(白盒测试)或 packagename_test(黑盒测试)package mathpackage math_test

测试文件的位置

myproject/
├── math/
│   ├── math.go           # 被测代码
│   └── math_test.go      # 测试代码(同目录)
└── go.mod

编写第一个测试

假设我们有如下被测代码:

// math/math.go
package math

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

// Divide 返回两个浮点数的商
func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("除数不能为零")
    }
    return a / b, nil
}

编写对应的测试:

// math/math_test.go
package math

import (
    "testing"
)

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

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

func TestDivide(t *testing.T) {
    result, err := Divide(10, 3)
    if err != nil {
        t.Fatalf("Divide(10, 3) 返回了意外错误: %v", err)
    }
    if result < 3.33 || result > 3.34 {
        t.Errorf("Divide(10, 3) = %f; 期望约 3.33", result)
    }
}

func TestDivideByZero(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Error("Divide(10, 0) 应该返回错误,但返回了 nil")
    }
}

运行测试:

$ go test ./math/
ok      myproject/math    0.002s

# 运行单个测试
$ go test -run TestAdd ./math/
ok      myproject/math    0.001s

# 显示详细输出
$ go test -v ./math/
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
=== RUN   TestAddNegative
--- PASS: TestAddNegative (0.00s)
=== RUN   TestDivide
--- PASS: TestDivide (0.00s)
=== RUN   TestDivideByZero
--- PASS: TestDivideByZero (0.00s)
PASS
ok      myproject/math    0.003s

t.Error / Errorf / Fatal / Fatalf

testing.T 提供了多个报告测试失败的方法:

方法行为适用场景
t.Error(args...)标记测试失败,继续执行非致命错误,想看所有失败
t.Errorf(format, args...)同上,支持格式化同上
t.Fatal(args...)标记测试失败,立即停止致命错误,后续代码无法执行
t.Fatalf(format, args...)同上,支持格式化同上
func TestUser(t *testing.T) {
    user, err := NewUser("Alice", 25)
    if err != nil {
        t.Fatalf("创建用户失败: %v", err)  // Fatal:user 为 nil,后续代码会 panic
    }

    if user.Name != "Alice" {
        t.Errorf("user.Name = %q; 期望 %q", user.Name, "Alice")  // Error:继续检查其他字段
    }

    if user.Age != 25 {
        t.Errorf("user.Age = %d; 期望 25", user.Age)
    }
}

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

表格驱动测试

表格驱动测试是 Go 社区推崇的测试模式。它将测试用例组织为结构体切片(表格),通过遍历表格来执行测试。这种方式使得添加新测试用例变得非常简单,也使测试代码更加清晰和可维护。

基本结构

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string  // 测试用例名称
        a, b     int     // 输入
        want     int     // 期望输出
    }{
        {"正数相加", 2, 3, 5},
        {"负数相加", -1, -2, -3},
        {"正负相加", 10, -3, 7},
        {"零值", 0, 0, 0},
        {"大数", 1000000, 2000000, 3000000},
    }

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

带错误的表格驱动测试

func TestDivide(t *testing.T) {
    tests := []struct {
        name    string
        a, b    float64
        want    float64
        wantErr bool
    }{
        {"正常除法", 10, 3, 3.333333, false},
        {"除以一", 5, 1, 5, false},
        {"除以负数", 10, -2, -5, false},
        {"除以零", 10, 0, 0, true},
        {"零除以正数", 0, 5, 0, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := Divide(tt.a, tt.b)
            if (err != nil) != tt.wantErr {
                t.Errorf("Divide(%v, %v) 错误 = %v, wantErr %v", tt.a, tt.b, err, tt.wantErr)
                return
            }
            if !tt.wantErr && math.Abs(got-tt.want) > 0.001 {
                t.Errorf("Divide(%v, %v) = %v, 期望 %v", tt.a, tt.b, got, tt.want)
            }
        })
    }
}

t.Helper()

t.Helper()

t.Helper() 将当前函数标记为测试辅助函数。被标记后,该函数报告的失败信息会显示在调用者的行号,而非辅助函数内部的行号。这使得错误定位更加准确。

为什么需要 t.Helper()

// ❌ 没有 t.Helper():错误指向辅助函数内部,难以定位
func assertEqual(t *testing.T, got, want int) {
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

// 测试输出:
// math_test.go:5: got 10, want 5
// ↑ 指向辅助函数内部,不知道是哪个测试用例失败了

// ✅ 使用 t.Helper():错误指向调用者
func assertEqual(t *testing.T, got, want int) {
    t.Helper()  // 标记为辅助函数
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

// 测试输出:
// math_test.go:15: got 10, want 5
// ↑ 指向调用 assertEqual 的那一行

常用辅助函数封装

// common_test.go
package math

import (
    "testing"
)

// assertEqual 断言两个整数相等
func assertEqual(t *testing.T, got, want int) {
    t.Helper()
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

// assertNoError 断言没有错误
func assertNoError(t *testing.T, err error) {
    t.Helper()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}

// assertError 断言有错误
func assertError(t *testing.T, err error) {
    t.Helper()
    if err == nil {
        t.Fatal("expected error, got nil")
    }
}

// 在测试中使用
func TestAdd(t *testing.T) {
    assertEqual(t, Add(2, 3), 5)
    assertEqual(t, Add(-1, -2), -3)
    assertEqual(t, Add(0, 0), 0)
}

子测试 t.Run()

t.Run() 用于创建子测试,可以将相关测试分组,并支持单独运行某个子测试:

func TestStack(t *testing.T) {
    // 子测试:Push 操作
    t.Run("Push", func(t *testing.T) {
        s := NewStack()
        s.Push(1)
        s.Push(2)
        assertEqual(t, s.Size(), 2)
    })

    // 子测试:Pop 操作
    t.Run("Pop", func(t *testing.T) {
        s := NewStack()
        s.Push(1)
        s.Push(2)
        val := s.Pop()
        assertEqual(t, val, 2)
        assertEqual(t, s.Size(), 1)
    })

    // 子测试:空栈 Pop
    t.Run("PopEmpty", func(t *testing.T) {
        s := NewStack()
        _, err := s.Pop()
        assertError(t, err)
    })

    // 子测试:并发安全
    t.Run("Concurrent", func(t *testing.T) {
        s := NewStack()
        var wg sync.WaitGroup
        for i := 0; i < 100; i++ {
            wg.Add(1)
            go func() {
                defer wg.Done()
                s.Push(i)
            }()
        }
        wg.Wait()
        assertEqual(t, s.Size(), 100)
    })
}

t.Run 的并行执行

func TestParallel(t *testing.T) {
    tests := []struct {
        name string
        url  string
    }{
        {"Google", "https://google.com"},
        {"GitHub", "https://github.com"},
        {"Go Dev", "https://go.dev"},
    }

    for _, tt := range tests {
        tt := tt // 捕获循环变量
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()  // 标记为并行测试
            resp, err := http.Get(tt.url)
            if err != nil {
                t.Fatalf("请求失败: %v", err)
            }
            defer resp.Body.Close()
            if resp.StatusCode != http.StatusOK {
                t.Errorf("状态码 %d,期望 200", resp.StatusCode)
            }
        })
    }
}

测试覆盖率 go test -cover

测试覆盖率(Code Coverage)

测试覆盖率衡量了被测试代码中被测试用例执行到的比例。Go 使用 -cover 标志来计算覆盖率百分比,使用 -coverprofile 生成详细的覆盖率报告。

基本用法

# 查看覆盖率百分比
$ go test -cover ./...
ok      myproject/math    0.002s  coverage: 87.5% of statements
ok      myproject/string    0.001s  coverage: 100.0% of statements

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

# 在终端查看覆盖率详情
$ go tool cover -func=coverage.out
myproject/math/math.go:10:    Add         100.0%
myproject/math/math.go:15:    Divide      75.0%
myproject/math/math.go:20:    Multiply    100.0%

# 生成 HTML 覆盖率报告(浏览器中查看)
$ go tool cover -html=coverage.out

设置最低覆盖率要求

# 覆盖率低于 80% 则测试失败
$ go test -cover ./... -coverprofile=coverage.out
$ echo "检查覆盖率..."
$ go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//'

也可以在 CI/CD 中使用工具(如 goveralls)来检查覆盖率:

// 使用 build tag 在测试中设置最低覆盖率
//go:build ignore

package main

import (
    "fmt"
    "os"
    "os/exec"
)

func main() {
    cmd := exec.Command("go", "test", "-cover", "./...")
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    if err := cmd.Run(); err != nil {
        fmt.Println("❌ 测试失败")
        os.Exit(1)
    }
}

t.Skip()

t.Skip() 用于跳过当前测试,适用于以下场景:

func TestDatabase(t *testing.T) {
    // 检查环境变量,如果没有设置数据库连接则跳过
    dsn := os.Getenv("TEST_DATABASE_DSN")
    if dsn == "" {
        t.Skip("跳过:未设置 TEST_DATABASE_DSN 环境变量")
    }

    db, err := sql.Open("mysql", dsn)
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()

    // ... 执行数据库测试 ...
}

func TestPlatformSpecific(t *testing.T) {
    // 跳过非 Linux 平台的测试
    if runtime.GOOS != "linux" {
        t.Skipf("跳过:当前平台 %s 不支持此测试", runtime.GOOS)
    }

    // ... Linux 特定的测试 ...
}

func TestSlowIntegration(t *testing.T) {
    // 只在 CI 环境中运行耗时测试
    if os.Getenv("CI") == "" {
        t.Skip("跳过:集成测试仅在 CI 环境中运行")
    }

    // ... 耗时的集成测试 ...
}
# 运行测试时跳过的测试会显示 SKIP 标记
$ go test -v ./...
=== RUN   TestDatabase
    main_test.go:10: 跳过:未设置 TEST_DATABASE_DSN 环境变量
--- SKIP: TestDatabase (0.00s)

Fixtures 与临时文件

t.TempDir()

Go 1.15+ 提供了 t.TempDir(),自动创建临时目录并在测试结束后清理:

func TestFileProcessing(t *testing.T) {
    // 创建临时目录,测试结束后自动删除
    tmpDir := t.TempDir()

    // 创建测试文件
    inputFile := filepath.Join(tmpDir, "input.txt")
    err := os.WriteFile(inputFile, []byte("hello world"), 0644)
    if err != nil {
        t.Fatal(err)
    }

    // 执行被测函数
    outputFile := filepath.Join(tmpDir, "output.txt")
    err = ProcessFile(inputFile, outputFile)
    if err != nil {
        t.Fatal(err)
    }

    // 验证输出文件
    data, err := os.ReadFile(outputFile)
    if err != nil {
        t.Fatal(err)
    }
    if string(data) != "HELLO WORLD" {
        t.Errorf("输出 = %q, 期望 %q", string(data), "HELLO WORLD")
    }

    // 不需要手动清理,t.TempDir() 会在测试结束后自动删除
}

测试数据文件(Testdata)

Go 约定使用 testdata 目录存放测试所需的固定数据文件:

myproject/
├── parser/
│   ├── parser.go
│   ├── parser_test.go
│   └── testdata/
│       ├── valid.json
│       ├── invalid.json
│       ├── empty.txt
│       └── malformed.xml
func TestParseJSON(t *testing.T) {
    // Go 测试可以读取 testdata 目录下的文件
    // 但 testdata 目录会被 go build 忽略,不会被编译到二进制中
    data, err := os.ReadFile("testdata/valid.json")
    if err != nil {
        t.Fatal(err)
    }

    result, err := ParseJSON(data)
    if err != nil {
        t.Fatalf("解析 JSON 失败: %v", err)
    }
    if result.Name != "test" {
        t.Errorf("Name = %q, 期望 %q", result.Name, "test")
    }
}

// 使用 t.Run + testdata 批量测试
func TestParseFiles(t *testing.T) {
    files, err := filepath.Glob("testdata/*.json")
    if err != nil {
        t.Fatal(err)
    }

    for _, file := range files {
        t.Run(filepath.Base(file), func(t *testing.T) {
            data, err := os.ReadFile(file)
            if err != nil {
                t.Fatal(err)
            }
            _, err = ParseJSON(data)
            // 根据文件名判断是否应该出错
            if strings.Contains(file, "invalid") || strings.Contains(file, "malformed") {
                if err == nil {
                    t.Error("期望解析失败,但成功了")
                }
            } else {
                if err != nil {
                    t.Errorf("解析失败: %v", err)
                }
            }
        })
    }
}

使用 Golden Files 验证输出

Golden File 测试是对比实际输出与预期文件的测试方式,适用于复杂输出的验证:

func TestRender(t *testing.T) {
    tmpl := `Hello, {{.Name}}!`
    data := map[string]string{"Name": "Go"}

    got := renderTemplate(tmpl, data)

    // 更新 golden file(首次运行或需要更新时)
    if *update {
        os.WriteFile("testdata/render.golden", []byte(got), 0644)
        return
    }

    // 读取 golden file 并比较
    want, err := os.ReadFile("testdata/render.golden")
    if err != nil {
        t.Fatal(err)
    }

    if !bytes.Equal([]byte(got), want) {
        t.Errorf("渲染结果不匹配\ngot:\n%s\nwant:\n%s", got, string(want))
    }
}
# 首次创建 golden file 或需要更新时
$ go test -run TestRender -args -update

练习题

练习 1:表格驱动测试

为以下字符串处理函数编写完整的表格驱动测试:

package stringutil

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

// IsPalindrome 判断字符串是否为回文
func IsPalindrome(s string) bool {
    return s == Reverse(s)
}
参考答案

测试代码

package stringutil

import "testing"

func TestReverse(t *testing.T) {
    tests := []struct {
        name  string
        input string
        want  string
    }{
        {"普通字符串", "hello", "olleh"},
        {"空字符串", "", ""},
        {"单个字符", "a", "a"},
        {"回文字符串", "madam", "madam"},
        {"中文字符串", "你好世界", "界世好你"},
        {"emoji", "👍👎", "👎👍"},
        {"混合内容", "Go语言", "言语oG"},
        {"偶数长度", "abcd", "dcba"},
        {"奇数长度", "abcde", "edcba"},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Reverse(tt.input)
            if got != tt.want {
                t.Errorf("Reverse(%q) = %q, 期望 %q", tt.input, got, tt.want)
            }
        })
    }
}

func TestIsPalindrome(t *testing.T) {
    tests := []struct {
        name  string
        input string
        want  bool
    }{
        {"回文", "madam", true},
        {"非回文", "hello", false},
        {"空字符串", "", true},
        {"单个字符", "a", true},
        {"数字回文", "12321", true},
        {"中文回文", "上海自来水来自海上", true},
        {"大小写敏感", "Madam", false},
        {"带空格", "a man a plan a canal panama", false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := IsPalindrome(tt.input)
            if got != tt.want {
                t.Errorf("IsPalindrome(%q) = %v, 期望 %v", tt.input, got, tt.want)
            }
        })
    }
}

练习 2:t.Helper() 与子测试

以下测试代码的输出文件和行号是什么?分析 t.Helper() 对错误报告位置的影响。

func TestWithHelper(t *testing.T) {
    checkEqual(t, 1, 2)
    checkEqual(t, 3, 3)
}

func checkEqual(t *testing.T, got, want int) {
    t.Helper()
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

假设 checkEqual 在第 10 行,TestWithHelper 在第 4 行。请写出 -v 模式下的输出。

参考答案

输出

=== RUN   TestWithHelper
    main_test.go:4: got 1, want 2
--- FAIL: TestWithHelper (0.00s)
FAIL

分析

  1. 第一行 checkEqual(t, 1, 2)1 != 2,调用 t.Errorf 报告失败
  2. 由于 checkEqual 中调用了 t.Helper(),错误报告的文件和行号指向调用者(第 4 行,即 TestWithHelper 函数中调用 checkEqual 的位置)
  3. 第二行 checkEqual(t, 3, 3)3 == 3,不报告错误
  4. 如果没有 t.Helper(),错误会指向 checkEqual 函数内部的 t.Errorf 行(第 13 行),我们需要在输出中看到:
    main_test.go:13: got 1, want 2
    这显然不如指向第 4 行直观,因为第 4 行才告诉我们是哪个具体的测试断言失败了。

练习 3:使用 t.TempDir() 和 testdata

编写一个函数 CountWordsInFile(path string) (int, error),它读取文件并统计单词数量。然后编写测试,使用 t.TempDir() 创建临时测试文件,并使用表格驱动测试覆盖各种情况(空文件、正常文件、无空格文件、多行文件)。

参考答案

被测代码

package wordcount

import (
    "bufio"
    "fmt"
    "io"
    "os"
    "strings"
)

func CountWordsInFile(path string) (int, error) {
    f, err := os.Open(path)
    if err != nil {
        return 0, fmt.Errorf("打开文件: %w", err)
    }
    defer f.Close()

    return CountWords(f)
}

func CountWords(r io.Reader) (int, error) {
    scanner := bufio.NewScanner(r)
    count := 0
    for scanner.Scan() {
        words := strings.Fields(scanner.Text())
        count += len(words)
    }
    if err := scanner.Err(); err != nil {
        return 0, fmt.Errorf("读取错误: %w", err)
    }
    return count, nil
}

测试代码

package wordcount

import (
    "os"
    "path/filepath"
    "strings"
    "testing"
)

func TestCountWordsInFile(t *testing.T) {
    tests := []struct {
        name    string
        content string
        want    int
        wantErr bool
    }{
        {
            name:    "正常文本",
            content: "hello world foo bar",
            want:    4,
        },
        {
            name:    "空文件",
            content: "",
            want:    0,
        },
        {
            name:    "只有空格和换行",
            content: "   \n\n  \n  ",
            want:    0,
        },
        {
            name:    "多行文本",
            content: "line one\nline two\nline three",
            want:    6,
        },
        {
            name:    "多个连续空格",
            content: "hello   world    foo",
            want:    3,
        },
        {
            name:    "单行单词",
            content: "hello",
            want:    1,
        },
        {
            name:    "制表符分隔",
            content: "hello\tworld\tfoo",
            want:    3,
        },
        {
            name:    "Unicode 文本",
            content: "你好 世界 Go 语言",
            want:    4,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // 使用 t.TempDir() 创建临时目录
            tmpDir := t.TempDir()
            filePath := filepath.Join(tmpDir, "test.txt")

            // 写入测试内容
            err := os.WriteFile(filePath, []byte(tt.content), 0644)
            if err != nil {
                t.Fatal(err)
            }

            got, err := CountWordsInFile(filePath)
            if (err != nil) != tt.wantErr {
                t.Errorf("CountWordsInFile() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if got != tt.want {
                t.Errorf("CountWordsInFile() = %d, 期望 %d", got, tt.want)
            }
        })
    }
}

// 使用 testdata 目录
func TestCountWordsFromTestdata(t *testing.T) {
    // 确认 testdata 目录存在
    entries, err := os.ReadDir("testdata")
    if err != nil {
        t.Skip("testdata 目录不存在,跳过测试")
    }

    for _, entry := range entries {
        if entry.IsDir() {
            continue
        }
        name := entry.Name()
        // 只测试 .txt 文件
        if !strings.HasSuffix(name, ".txt") {
            continue
        }
        t.Run(name, func(t *testing.T) {
            path := filepath.Join("testdata", name)
            f, err := os.Open(path)
            if err != nil {
                t.Fatal(err)
            }
            defer f.Close()

            count, err := CountWords(f)
            if err != nil {
                t.Errorf("CountWords(%s) 返回错误: %v", name, err)
            }
            // 可以使用 golden file 验证预期数量
            // 也可以简单打印(手动验证后转为断言)
            t.Logf("文件 %s 包含 %d 个单词", name, count)
        })
    }
}

搜索