目录
- [🟠 Go 入门到精通:测试与基准](#🟠 Go 入门到精通:测试与基准)
-
- 开篇:测试不是可选项
- [1. testing 包快速入门](#1. testing 包快速入门)
-
- [1.1 第一个测试](#1.1 第一个测试)
- [1.2 运行测试](#1.2 运行测试)
- [1.3 测试命名约定](#1.3 测试命名约定)
- [2. 表驱动测试 Table-Driven Tests](#2. 表驱动测试 Table-Driven Tests)
- [3. 子测试 t.Run](#3. 子测试 t.Run)
- [4. 测试辅助函数与 t.Helper](#4. 测试辅助函数与 t.Helper)
- [5. TestMain:全局 Setup 与 Teardown](#5. TestMain:全局 Setup 与 Teardown)
- [6. 基准测试 Benchmark](#6. 基准测试 Benchmark)
-
- [6.1 基本用法](#6.1 基本用法)
- [6.2 ResetTimer 与 StopTimer](#6.2 ResetTimer 与 StopTimer)
- [6.3 并行基准测试](#6.3 并行基准测试)
- [6.4 基准测试对比](#6.4 基准测试对比)
- [7. 并行测试 t.Parallel](#7. 并行测试 t.Parallel)
- [8. Fuzzing 模糊测试](#8. Fuzzing 模糊测试)
- [9. 常用测试工具与库](#9. 常用测试工具与库)
-
- [9.1 testify 断言库](#9.1 testify 断言库)
- [9.2 gomock / testify mock](#9.2 gomock / testify mock)
- [9.3 代码覆盖率](#9.3 代码覆盖率)
- [10. 测试最佳实践](#10. 测试最佳实践)
- 小结与互动
🟠 Go 入门到精通:测试与基准
📅 更新于 2026年7月 | ✍️ 原创文章,转载请注明出处 | 🧑💻 作者:布朗克168
开篇:测试不是可选项
🧠 Go 语言的文化基因中,测试是与生俱来的。Go 不需要第三方测试框架------标准库
testing包提供了单元测试、基准测试、示例测试和模糊测试的全部能力。
一门语言对待测试的态度,决定了它的工程成熟度。Go 的哲学是:测试文件与源文件共存于同一包中,编译也不剔除测试代码,用约定代替配置。
本文从零开始,系统讲解 Go 测试的完整体系:从最基本的 TestXxx 到表驱动测试,从基准测试到模糊测试,从标准库到 testify/gomock 生态。
1. testing 包快速入门
1.1 第一个测试
假设我们有这样一个函数:
go
// math.go
package calculator
func Add(a, b int) int {
return a + b
}
测试文件命名为 math_test.go,放在同一目录下:
go
// math_test.go
package calculator
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)
}
}
1.2 运行测试
bash
# 运行当前包的所有测试
go test
# 显示详细输出
go test -v
# 运行指定测试函数
go test -run TestAdd
# 运行整个项目的所有测试
go test ./...
# 输出覆盖率
go test -cover
# 生成覆盖率报告
go test -coverprofile=coverage.out
go tool cover -html=coverage.out
1.3 测试命名约定
| 约定 | 说明 |
|---|---|
| 文件名 | *_test.go |
| 函数名 | TestXxx(t *testing.T) |
| 基准测试 | BenchmarkXxx(b *testing.B) |
| 示例测试 | ExampleXxx(),输出用 // Output: 注释 |
| 模糊测试 | FuzzXxx(f *testing.F)(Go 1.18+) |
go
// 示例测试:将被自动执行并验证 Output 注释的内容
func ExampleAdd() {
fmt.Println(Add(2, 3))
// Output:
// 5
}
2. 表驱动测试 Table-Driven Tests
表驱动测试是 Go 社区最推崇的测试模式。将测试用例以结构体切片的形式定义,循环执行:
go
func TestAdd_TableDriven(t *testing.T) {
tests := []struct {
name string // 用例名称
a, b int // 输入参数
expected int // 期望结果
}{
{"正数相加", 2, 3, 5},
{"零值相加", 0, 0, 0},
{"负数相加", -1, -2, -3},
{"正负抵消", 5, -5, 0},
{"大数相加", 1<<30, 1 << 30, 1 << 31},
}
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)
}
})
}
}
表驱动测试的优点:
| 优点 | 说明 |
|---|---|
| ✅ 易扩展 | 新增用例只需在切片中加一行 |
| ✅ 结构清晰 | 输入、期望输出、用例名一目了然 |
| ✅ 独立执行 | -run TestAdd/正数相加 可单独跑一个用例 |
| ✅ 减少重复 | 测试逻辑只写一次 |
3. 子测试 t.Run
t.Run(name, func) 创建命名子测试,好处包括:
go
func TestDivide(t *testing.T) {
t.Run("正常除法", func(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Fatal("unexpected error:", err)
}
if result != 5 {
t.Errorf("got %d, want 5", result)
}
})
t.Run("除数为零", func(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Fatal("expected error but got nil")
}
})
}
bash
# 单独运行某个子测试
go test -run TestDivide/正常除法 -v
🎯 子测试 + 表驱动 = 每个用例独立、可选择运行、失败不影响其他用例。
4. 测试辅助函数与 t.Helper
当测试逻辑复杂时,我们会提取辅助函数。t.Helper() 让 Go 在测试失败时报告调用者位置,而非辅助函数内部位置:
go
// 自定义断言辅助函数
func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper() // ← 关键:标记为辅助函数
if got != want {
t.Errorf("got %v, want %v", 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) {
result := Add(2, 3)
assertEqual(t, result, 5) // 失败时指向此行,而非 assertEqual 内部
}
t.Helper() 的作用 |
无 Helper | 有 Helper |
|---|---|---|
| 失败堆栈指向 | 辅助函数内部 | 调用辅助函数的测试代码行 |
| IDE 跳转 | 跳转到辅助函数 | 跳转到测试代码 |
| 排查效率 | 🔴 低 | 🟢 高 |
5. TestMain:全局 Setup 与 Teardown
当需要在所有测试之前执行初始化(如数据库连接、配置加载),或在所有测试之后清理资源时,使用 TestMain:
go
var testDB *sql.DB
func TestMain(m *testing.M) {
// === Setup:所有测试运行前执行 ===
var err error
testDB, err = sql.Open("mysql", "test_dsn")
if err != nil {
fmt.Fprintln(os.Stderr, "无法连接测试数据库:", err)
os.Exit(1)
}
defer testDB.Close()
// 运行迁移(确保表结构)
if err := migrateDB(testDB); err != nil {
os.Exit(1)
}
// === 运行所有测试 ===
exitCode := m.Run()
// === Teardown:所有测试运行后执行 ===
cleanupTestData(testDB)
os.Exit(exitCode)
}
func TestUserCreate(t *testing.T) {
// 使用 testDB...
}
⚠️
TestMain一个包只能有一个。如果定义了TestMain,就必须通过m.Run()来运行测试,否则所有测试都不会执行。
6. 基准测试 Benchmark
6.1 基本用法
基准测试函数以 BenchmarkXxx 命名,参数类型为 *testing.B:
go
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(100, 200)
}
}
// 基准测试字符串拼接方式对比
func BenchmarkConcatPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = "hello" + " " + "world"
}
}
func BenchmarkConcatFmt(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = fmt.Sprintf("%s %s", "hello", "world")
}
}
func BenchmarkConcatBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
sb.WriteString("hello")
sb.WriteString(" ")
sb.WriteString("world")
_ = sb.String()
}
}
bash
# 运行基准测试
go test -bench=. -benchmem
# 输出示例:
# BenchmarkAdd-8 1000000000 0.3124 ns/op 0 B/op 0 allocs/op
# BenchmarkConcatPlus-8 1000000000 0.2851 ns/op 0 B/op 0 allocs/op
# BenchmarkConcatFmt-8 20000000 92.15 ns/op 16 B/op 1 allocs/op
# BenchmarkConcatBuilder-8 50000000 23.48 ns/op 8 B/op 1 allocs/op
输出解读:
| 字段 | 含义 |
|---|---|
BenchmarkAdd-8 |
使用 8 个 CPU 核心 |
1000000000 |
执行了 10 亿次 |
0.3124 ns/op |
每次操作 0.3124 纳秒 |
0 B/op |
每次操作 0 字节内存分配 |
0 allocs/op |
每次操作 0 次内存分配 |
6.2 ResetTimer 与 StopTimer
当测试循环前后有准备和清理工作,需要排除其时间影响:
go
func BenchmarkPrepareData(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++ {
sort.Ints(data)
}
}
func BenchmarkWithIOLike(t *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer() // 暂停计时
// 模拟耗时准备工作(不纳入统计)
time.Sleep(1 * time.Millisecond)
b.StartTimer() // 恢复计时
Add(1, 2)
}
}
6.3 并行基准测试
go
func BenchmarkAddParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Add(100, 200)
}
})
}
// 输出:BenchmarkAddParallel-8 5000000000 0.2500 ns/op
6.4 基准测试对比
bash
# 与之前的基准测试对比
go test -bench=. -count=5 > old.txt
# ... 修改代码 ...
go test -bench=. -count=5 > new.txt
benchstat old.txt new.txt
benchstat 是 Go 官方工具(golang.org/x/perf/cmd/benchstat),可以统计显著性差异,避免把噪音当优化。
7. 并行测试 t.Parallel
t.Parallel() 让多个测试函数并发执行,加快测试速度:
go
func TestSlow1(t *testing.T) {
t.Parallel()
time.Sleep(100 * time.Millisecond)
// 测试逻辑...
}
func TestSlow2(t *testing.T) {
t.Parallel()
time.Sleep(100 * time.Millisecond)
}
func TestSlow3(t *testing.T) {
t.Parallel()
time.Sleep(100 * time.Millisecond)
}
// 串行:300ms;并行:~100ms
bash
# 控制并行数(默认 GOMAXPROCS)
go test -parallel 4
⚠️ 并行测试要求测试之间完全独立,不能共享可变状态。如果依赖
TestMain中的全局资源,确保资源是并发安全的。
8. Fuzzing 模糊测试
Go 1.18 引入了原生模糊测试(Fuzzing),它能自动生成随机输入来发现边界条件和 bug:
go
func FuzzDivide(f *testing.F) {
// 种子语料
f.Add(10, 2)
f.Add(20, 5)
f.Add(0, 1)
f.Fuzz(func(t *testing.T, a, b int) {
result, err := Divide(a, b)
if b == 0 {
if err == nil {
t.Errorf("除数为 0 应返回 error:Divide(%d, %d) = %d", a, b, result)
}
return
}
if err != nil {
t.Fatalf("非零除数不应该返回 error:Divide(%d, %d): %v", a, b, err)
}
// 验证:结果 * 除数 + 余数 = 被除数(整数除法存在截断)
})
}
bash
# 运行模糊测试(默认无限运行,直到发现错误)
go test -fuzz=FuzzDivide -fuzztime=30s
| Fuzzing 特性 | 说明 |
|---|---|
| 种子语料 | f.Add() 添加已知输入作为起点 |
| 自动变异 | 基于覆盖率的引导式变异(coverage-guided) |
| 最小化 | 发现失败后自动缩到最小复现输入 |
| 语料持久化 | 失败和有趣的输入自动存到 testdata/fuzz/ |
9. 常用测试工具与库
9.1 testify 断言库
标准库只有 t.Error/t.Fatal 系列,没有 assert。testify 是事实标准:
go
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWithTestify(t *testing.T) {
assert.Equal(t, 5, Add(2, 3))
assert.NotNil(t, &User{})
assert.Contains(t, []string{"a", "b", "c"}, "b")
assert.Greater(t, 10, 5)
// require 失败时立即终止(等于 t.Fatal),assert 继续执行
db, err := connectDB()
require.NoError(t, err) // 连接失败则测试无意义,立即停止
require.NotNil(t, db)
}
| 对比 | assert |
require |
|---|---|---|
| 失败行为 | t.Error(继续执行) |
t.FatalNow(立即终止) |
| 适用场景 | 非致命校验 | 后续代码依赖此条件 |
9.2 gomock / testify mock
当需要模拟外部依赖时:
go
// 使用 testify/mock
type MockUserRepo struct {
mock.Mock
}
func (m *MockUserRepo) FindByID(id int) (*User, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func TestGetUserInfo(t *testing.T) {
mockRepo := new(MockUserRepo)
mockRepo.On("FindByID", 1).Return(&User{ID: 1, Name: "Alice"}, nil)
mockRepo.On("FindByID", 999).Return(nil, errors.New("not found"))
service := NewUserService(mockRepo)
user, err := service.GetUserInfo(1)
assert.NoError(t, err)
assert.Equal(t, "Alice", user.Name)
_, err = service.GetUserInfo(999)
assert.Error(t, err)
mockRepo.AssertExpectations(t) // 验证所有期望调用都被满足
}
9.3 代码覆盖率
bash
# 生成覆盖率报告
go test -coverprofile=coverage.out ./...
# 按函数查看覆盖率
go tool cover -func=coverage.out
# 在浏览器中可视化(绿色=覆盖,红色=未覆盖)
go tool cover -html=coverage.out
coverage 结果显示:
text
calculator/math.go:5: Add 100.0%
calculator/math.go:9: Divide 85.7%
total: (statements) 92.3%
📊 关注覆盖率趋势而非绝对值。80% 覆盖率的 CRUD 代码可能有效,90% 覆盖率的复杂业务逻辑也可能漏掉关键路径。
10. 测试最佳实践
| 实践 | 说明 |
|---|---|
| 📝 表驱动优先 | 95% 的测试应该用表驱动模式 |
| 🧹 每个测试独立 | 不依赖执行顺序、不共享可变状态 |
| 🏷️ 用例名有意义 | t.Run("除数为零应返回错误") 而非 t.Run("case1") |
| 🔧 Helper 加标记 | 辅助函数统一 t.Helper() |
| ⚡ 单元测试要快 | 单个测试函数应 < 100ms |
| 🔗 依赖外部用 mock | 数据库/HTTP/RPC 用 mock 替代 |
| 📈 CI 中检查覆盖率 | 设置覆盖率阈值,防止退化 |
| 🐛 发现 bug 先写测试 | 先写复现 bug 的测试,再修复 |
| 🔄 提交前跑全量测试 | go test ./... 是最后的底线 |
测试金字塔(Go 实践版):
text
┌───────┐
│ E2E │ 少量,验证关键链路
┌┴───────┴┐
│集成测试 │ 中等,验证组件协作
┌┴─────────┴┐
│ 单元测试 │ 大量,验证函数逻辑
└───────────┘
小结与互动
🎯 核心要点回顾:
| 层次 | 关键内容 |
|---|---|
| 基础 | TestXxx、*testing.T、go test |
| 核心模式 | 表驱动测试 + t.Run 子测试 |
| 辅助 | t.Helper()、TestMain |
| 性能 | BenchmarkXxx、b.ResetTimer、benchstat |
| 并发 | t.Parallel() |
| 高级 | Fuzzing 模糊测试(Go 1.18+) |
| 生态 | testify、gomock、覆盖率工具 |
📣 好的测试是一种工程习惯。不是"写完功能再补测试",而是"写完测试再用代码满足测试"。Go 标准库已经给了你全部需要的东西,现在只缺你的测试代码。
💬 互动话题:你的团队测试覆盖率是多少?有没有遇到过"测试全绿但线上故障"的情况?欢迎分享测试经验,一起提升代码质量!
下一篇预告:第30篇《Go 入门到精通:网络编程》------从 TCP Socket 到 HTTP 中间件的全景实战。