📖 测试¶
学习时间: 约 4-5 小时 | 难度: ⭐⭐⭐ 中级 | 前置知识: Go基础语法、包管理
📚 章节概述¶
Go 在语言层面内建了测试支持——go test 命令和 testing 包。无需任何第三方框架就能编写单元测试、基准测试、模糊测试和示例测试。本章将全面讲解 Go 的测试体系,从基础测试到高级技巧,帮助你构建可靠的测试套件。
上图概括了从单元测试到集成测试的层次结构,并对应 Go 原生测试工具链。
🎯 学习目标¶
- 掌握单元测试和表驱动测试
- 学会子测试、并行测试和 TestMain
- 精通基准测试和性能分析
- 了解模糊测试(Go 1.18+)
- 掌握 Mock、Stub 等测试技巧
- 理解测试覆盖率和 CI 集成
📌 概念:单元测试基础¶
1.1 测试文件命名约定¶
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 编写基本测试¶
// 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
}
// 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 的常用方法¶
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 运行测试¶
# 基本运行
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 标准表驱动模式¶
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 带错误场景的表驱动测试¶
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)¶
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 并行测试¶
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¶
// TestMain 控制整个测试包的生命周期
func TestMain(m *testing.M) {
// 全局 setup
fmt.Println("=== 初始化测试环境 ===")
db := setupTestDB()
// 运行所有测试
exitCode := m.Run()
// 全局 teardown
fmt.Println("=== 清理测试环境 ===")
db.Close()
os.Exit(exitCode)
}
📌 概念:基准测试¶
4.1 编写基准测试¶
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 运行基准测试¶
# 运行基准测试
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 生成覆盖率报告¶
# 查看覆盖率百分比
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+)¶
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))
}
})
}
# 运行模糊测试
go test -fuzz=FuzzReverse # 持续运行直到发现 bug 或 Ctrl+C
go test -fuzz=FuzzReverse -fuzztime=30s # 运行 30 秒
💻 代码示例:实战测试技巧¶
示例1:HTTP Handler 测试¶
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:测试辅助函数¶
// 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 接口¶
// 使用接口实现简单 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. 测试函数命名规范¶
// 格式: 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 做清理¶
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 接口的实现模式