单元测试
单元测试是对程序中最小可测试单元(通常是函数或方法)进行验证的过程。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 math 或 package 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.003st.Error / Errorf / Fatal / Fatalf
testing.T 提供了多个报告测试失败的方法:
| 方法 | 行为 | 适用场景 |
|---|---|---|
t.Error(args...) | 标记测试失败,继续执行 | 非致命错误,想看所有失败 |
t.Errorf(format, args...) | 同上,支持格式化 | 同上 |
t.Fatal(args...) | 标记测试失败,立即停止 | 致命错误,后续代码无法执行 |
t.Fatalf(format, args...) | 同上,支持格式化 | 同上 |
Error vs Fatal 的选择
- 使用
t.Error/t.Errorf:当你想继续执行后续断言,一次看到所有失败点 - 使用
t.Fatal/t.Fatalf:当错误会导致后续代码 panic(如 nil 指针解引用),必须立即停止 - 推荐默认使用
t.Errorf,只在必要时使用t.Fatalf
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():错误指向辅助函数内部,难以定位
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)
})
}运行子测试
# 运行所有 Stack 测试
$ go test -run TestStack -v
# 只运行 Pop 子测试
$ go test -run "TestStack/Pop" -v
# 运行多个子测试
$ go test -run "TestStack/(Push|Pop)" -vt.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)
}
})
}
}t.Parallel() 的注意事项
- 并行测试独立运行,不应共享可变状态
- 循环中使用
t.Run时,需要在循环体内重新捕获变量tt := tt(Go 1.22+ 不再需要) - 可以用
-parallel参数限制并行测试的最大数量
测试覆盖率 go test -cover
测试覆盖率衡量了被测试代码中被测试用例执行到的比例。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覆盖率的目标
- 70-80% 通常是理想的覆盖率目标
- 不要盲目追求 100% 覆盖率——有些代码难以测试(如 panic 分支)
- 关注关键业务逻辑的覆盖率,而非简单 getter/setter
- 覆盖率高不代表测试质量高——需要关注测试的有效性
设置最低覆盖率要求
# 覆盖率低于 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() 用于跳过当前测试,适用于以下场景:
跳过测试的典型场景
- 缺少外部依赖(如数据库、网络服务)
- 特定平台不支持的测试
- 耗时过长,仅在 CI 中运行的测试
- 需要特殊权限的测试
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() 会在测试结束后自动删除
}t.TempDir() 的优势
- 每个测试有独立的临时目录,避免测试间相互干扰
- 测试结束后自动清理,即使测试失败也会清理
- 临时目录位于系统的临时目录中(如
/tmp) - 比手动创建和管理临时目录更安全可靠
测试数据文件(Testdata)
Go 约定使用 testdata 目录存放测试所需的固定数据文件:
myproject/
├── parser/
│ ├── parser.go
│ ├── parser_test.go
│ └── testdata/
│ ├── valid.json
│ ├── invalid.json
│ ├── empty.txt
│ └── malformed.xmlfunc 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分析:
- 第一行
checkEqual(t, 1, 2):1 != 2,调用t.Errorf报告失败 - 由于
checkEqual中调用了t.Helper(),错误报告的文件和行号指向调用者(第 4 行,即TestWithHelper函数中调用checkEqual的位置) - 第二行
checkEqual(t, 3, 3):3 == 3,不报告错误 - 如果没有
t.Helper(),错误会指向checkEqual函数内部的t.Errorf行(第 13 行),我们需要在输出中看到:
这显然不如指向第 4 行直观,因为第 4 行才告诉我们是哪个具体的测试断言失败了。main_test.go:13: got 1, want 2
练习 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)
})
}
}