6.7 Go 单元测试完全指南
1. 为什么需要单元测试?
在软件开发中,单元测试是保证代码质量的重要手段。通过编写测试用例,我们可以:
- ✅ 及早发现和修复 bug
- ✅ 确保代码重构后功能不被破坏
- ✅ 提供代码的使用示例
- ✅ 提高代码的可维护性
Go 语言内置了testing 包,让单元测试变得简单而优雅。
2. 单元测试基础
2.1 测试文件命名规则
Go 的测试文件必须遵循以下规则:
- 测试文件以
_test.go结尾 - 测试文件与被测试代码在同一个包(package)中
- 测试函数必须以
Test开头,后跟大写字母
示例:
项目结构:
myproject/
├── split.go # 业务代码
└── split_test.go # 测试代码2.2 第一个测试函数
假设我们有一个字符串分割函数 Split:
// split.go
package split
import "strings"
// Split 分割字符串
func Split(s, sep string) []string {
var result []string
index := strings.Index(s, sep)
for index >= 0 {
result = append(result, s[:index])
s = s[index+len(sep):]
index = strings.Index(s, sep)
}
result = append(result, s)
return result
}为它编写测试:
// split_test.go
package split
import (
"reflect"
"testing"
)
func TestSplit(t *testing.T) {
got := Split("a:b:c", ":")
want := []string{"a", "b", "c"}
if !reflect.DeepEqual(got, want) {
t.Errorf("期望:%v,实际:%v", want, got)
}
}2.3 运行测试
# 运行当前目录下的所有测试
go test
# 显示详细测试信息
go test -v
# 运行指定的测试函数
go test -run TestSplit
# 查看测试覆盖率
go test -cover输出示例:
=== RUN TestSplit
--- PASS: TestSplit (0.00s)
PASS
ok split 0.006s3. testing.T 的常用方法
在编写测试时,testing.T 类型提供了多种方法来报告测试结果、记录日志等。
3.1 日志输出:t.Log / t.Logf
用于输出测试日志,只有在测试失败或使用 -v 参数时才会显示。
func TestLog(t *testing.T) {
t.Log("这是一条普通日志")
t.Logf("格式化日志:%s = %d", "count", 42)
}3.2 错误报告:t.Error / t.Errorf
报告测试失败,但继续执行后续代码。适合检查多个独立条件。
func TestMultipleChecks(t *testing.T) {
result := Calculate(2, 3)
if result != 5 {
t.Errorf("Calculate(2, 3) = %d, want 5", result)
}
// 即使上面失败,这里依然会执行
if result < 0 {
t.Error("结果不应该为负数")
}
}3.3 致命错误:t.Fatal / t.Fatalf
报告测试失败,并立即终止测试。适合前置条件失败的场景。
func TestDatabase(t *testing.T) {
db, err := OpenDatabase()
if err != nil {
t.Fatalf("无法打开数据库: %v", err)
// 后面的代码不会执行
}
defer db.Close()
// 数据库打开成功后才会执行到这里
}3.4 Error 与 Fatal 的选择
| 方法 | 行为 | 使用场景 |
|---|---|---|
t.Error/Errorf | 报告错误,继续执行 | 检查多个独立条件 |
t.Fatal/Fatalf | 报告错误,立即退出 | 前置条件失败,后续无法继续 |
示例对比:
// 使用 Error:检查多个字段
func TestPerson(t *testing.T) {
person := GetPerson()
if person.Name != "Alice" {
t.Errorf("Name = %s, want Alice", person.Name)
}
if person.Age != 30 {
t.Errorf("Age = %d, want 30", person.Age)
}
// 所有错误都会被报告
}
// 使用 Fatal:前置条件失败
func TestAPI(t *testing.T) {
client, err := NewClient()
if err != nil {
t.Fatalf("创建客户端失败: %v", err)
}
// client 为 nil 时后续测试无意义
}3.5 跳过测试:t.Skip / t.Skipf
条件不满足时跳过测试。
func TestLinuxOnly(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("此测试仅在 Linux 下运行")
}
// 测试代码...
}
func TestIntegration(t *testing.T) {
if testing.Short() {
t.Skip("跳过耗时测试(-short 模式)")
}
// 集成测试代码...
}3.6 辅助函数:t.Helper
标记测试辅助函数,让错误信息更准确。
// 辅助函数
func assertEqual(t *testing.T, got, want int) {
t.Helper() // 标记为辅助函数
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
// 测试中使用
func TestCalculate(t *testing.T) {
result := Calculate(2, 3)
assertEqual(t, result, 5) // 错误会报告在这一行,而不是 assertEqual 内部
}3.7 常用方法速查
| 方法 | 说明 | 典型用法 |
|---|---|---|
t.Log/Logf | 输出日志(需要 -v) | 调试信息 |
t.Error/Errorf | 报告错误,继续执行 | 多个独立检查 |
t.Fatal/Fatalf | 报告错误,立即终止 | 前置条件失败 |
t.Skip/Skipf | 跳过测试 | 条件不满足 |
t.Helper | 标记辅助函数 | 提取公共验证逻辑 |
3.8 最佳实践
// ✅ 好的做法:提供详细的错误信息
t.Errorf("Split(%q, %q) = %v, want %v", input, sep, got, want)
// ❌ 不好的做法:错误信息不明确
t.Error("failed")
// ✅ 好的做法:前置条件使用 Fatal
db, err := OpenDB()
if err != nil {
t.Fatalf("无法打开数据库: %v", err)
}
// ✅ 好的做法:辅助函数使用 Helper
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}4. 表格驱动测试(Table-Driven Tests)
4.1 什么是表格驱动测试?
表格驱动测试是 Go 社区推荐的最佳实践,它将多个测试用例组织成一个表格(切片或 map),然后循环执行。
优点:
- 减少重复代码
- 易于添加新的测试用例
- 测试逻辑清晰明了
4.2 使用切片的表格驱动测试
func TestSplit(t *testing.T) {
// 定义测试用例结构体
type testCase struct {
input string
sep string
want []string
}
// 测试用例表格
tests := []testCase{
{input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
{input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
{input: "abcd", sep: "bc", want: []string{"a", "d"}},
{input: "枯藤老树昏鸦", sep: "老", want: []string{"枯藤", "树昏鸦"}},
}
// 遍历测试用例
for _, tc := range tests {
got := Split(tc.input, tc.sep)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("Split(%q, %q) = %v, want %v",
tc.input, tc.sep, got, tc.want)
}
}
}4.3 使用 map 的表格驱动测试
使用 map 可以为每个测试用例命名,让测试结果更清晰:
func TestSplit(t *testing.T) {
type testCase struct {
input string
sep string
want []string
}
// 使用 map 存储测试用例,key 为测试用例名称
tests := map[string]testCase{
"simple": {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
"wrong_sep": {input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
"multi_char": {input: "abcd", sep: "bc", want: []string{"a", "d"}},
"chinese": {input: "枯藤老树昏鸦", sep: "老", want: []string{"枯藤", "树昏鸦"}},
}
for name, tc := range tests {
got := Split(tc.input, tc.sep)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("[%s] Split(%q, %q) = %v, want %v",
name, tc.input, tc.sep, got, tc.want)
}
}
}5. 子测试(Subtests)
5.1 为什么需要子测试?
使用 t.Run() 可以将每个测试用例作为独立的子测试运行,好处是:
- 可以单独运行某个子测试
- 测试失败时更容易定位问题
- 支持并行测试
5.2 子测试示例
func TestSplit(t *testing.T) {
type testCase struct {
input string
sep string
want []string
}
tests := map[string]testCase{
"simple": {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
"wrong_sep": {input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
"multi_char": {input: "abcd", sep: "bc", want: []string{"a", "d"}},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := Split(tc.input, tc.sep)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("expected: %v, got: %v", tc.want, got)
}
})
}
}运行指定子测试:
# 运行 TestSplit 下的 simple 子测试
go test -run TestSplit/simple -v
# 运行所有名称包含 "char" 的子测试
go test -run TestSplit/char -v输出示例:
=== RUN TestSplit
=== RUN TestSplit/simple
=== RUN TestSplit/wrong_sep
=== RUN TestSplit/multi_char
--- PASS: TestSplit (0.00s)
--- PASS: TestSplit/simple (0.00s)
--- PASS: TestSplit/wrong_sep (0.00s)
--- PASS: TestSplit/multi_char (0.00s)
PASS5.3 并行测试
使用 t.Parallel() 可以让子测试并行执行,提高测试速度:
func TestSplitParallel(t *testing.T) {
tests := map[string]struct{
input string
sep string
want []string
}{
"simple": {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
"chinese": {input: "枯藤老树昏鸦", sep: "老", want: []string{"枯藤", "树昏鸦"}},
}
for name, tc := range tests {
tc := tc // 注意:并行测试需要重新赋值
t.Run(name, func(t *testing.T) {
t.Parallel() // 标记为并行测试
got := Split(tc.input, tc.sep)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("expected: %v, got: %v", tc.want, got)
}
})
}
}6. 测试辅助函数
6.1 使用 t.Helper()
当测试代码中有重复的验证逻辑时,可以提取成辅助函数,使用 t.Helper() 标记:
// 测试辅助函数
func assertResult(t *testing.T, got, want []string) {
t.Helper() // 标记为辅助函数,错误时会报告调用者的行号
if !reflect.DeepEqual(got, want) {
t.Errorf("expected: %v, got: %v", want, got)
}
}
func TestSplit(t *testing.T) {
tests := []struct {
input string
sep string
want []string
}{
{"a:b:c", ":", []string{"a", "b", "c"}},
{"a:b:c", ",", []string{"a:b:c"}},
}
for _, tc := range tests {
got := Split(tc.input, tc.sep)
assertResult(t, got, tc.want)
}
}7. 基准测试(Benchmark)
7.1 什么是基准测试?
基准测试用于测试代码的性能,帮助我们:
- 测量函数的执行时间
- 对比不同实现的性能
- 发现性能瓶颈
7.2 编写基准测试
基准测试函数以 Benchmark 开头,接收 *testing.B 参数:
func BenchmarkSplit(b *testing.B) {
// b.N 会自动调整,以获得可靠的测试结果
for i := 0; i < b.N; i++ {
Split("枯藤老树昏鸦", "老")
}
}运行基准测试:
# 运行所有基准测试
go test -bench=.
# 运行指定基准测试
go test -bench=BenchmarkSplit
# 显示内存分配情况
go test -bench=. -benchmem输出示例:
BenchmarkSplit-8 10000000 131 ns/op 48 B/op 3 allocs/op解读:
BenchmarkSplit-8:测试名称-CPU核心数10000000:执行了 1000 万次131 ns/op:每次操作耗时 131 纳秒48 B/op:每次操作分配 48 字节内存3 allocs/op:每次操作进行 3 次内存分配
7.3 性能优化实战
假设我们发现 Split 函数性能不佳,可以优化为:
// 优化版本:预分配切片容量
func SplitV2(s, sep string) []string {
result := make([]string, 0, strings.Count(s, sep)+1)
index := strings.Index(s, sep)
for index >= 0 {
result = append(result, s[:index])
s = s[index+len(sep):]
index = strings.Index(s, sep)
}
result = append(result, s)
return result
}
func BenchmarkSplitV2(b *testing.B) {
for i := 0; i < b.N; i++ {
SplitV2("枯藤老树昏鸦", "老")
}
}对比两个版本的性能:
go test -bench=. -benchmem7.4 重置计时器
如果基准测试前需要做一些准备工作,可以使用 b.ResetTimer() 重置计时器:
func BenchmarkSplitWithSetup(b *testing.B) {
// 准备测试数据(不计入性能测试时间)
testData := generateLargeString()
b.ResetTimer() // 重置计时器
for i := 0; i < b.N; i++ {
Split(testData, ":")
}
}7.5 并行基准测试
测试在多核 CPU 下的性能表现:
func BenchmarkSplitParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Split("枯藤老树昏鸦", "老")
}
})
}8. 测试覆盖率
8.1 查看测试覆盖率
# 显示覆盖率百分比
go test -cover
# 生成覆盖率报告
go test -coverprofile=coverage.out
# 查看详细的覆盖率报告
go tool cover -html=coverage.out8.2 分析覆盖率报告
执行后会在浏览器中打开 HTML 报告:
- 绿色:被测试覆盖的代码
- 红色:未被测试覆盖的代码
- 灰色:不需要测试的代码(如注释)
覆盖率建议:
- 核心业务逻辑:≥ 80%
- 工具函数:≥ 90%
- 边界情况和错误处理:100%
9. 测试技巧和最佳实践
9.1 表格驱动测试的高级用法
技巧 1:使用匿名结构体简化代码
func TestSplit(t *testing.T) {
tests := []struct {
name string
input string
sep string
want []string
}{
{"正常分割", "a:b:c", ":", []string{"a", "b", "c"}},
{"分隔符不存在", "abc", ":", []string{"abc"}},
{"空字符串", "", ":", []string{""}},
{"连续分隔符", "a::b", ":", []string{"a", "", "b"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Split(tt.input, tt.sep)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}技巧 2:测试错误情况
func TestSplitError(t *testing.T) {
tests := []struct {
name string
input string
sep string
wantError bool
}{
{"空分隔符", "abc", "", true},
{"分隔符太长", "ab", "abc", true},
{"正常情况", "a:b", ":", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := SplitWithError(tt.input, tt.sep)
if (err != nil) != tt.wantError {
t.Errorf("wantError = %v, got error = %v", tt.wantError, err)
}
})
}
}9.2 Mock 和依赖注入
对于依赖外部服务的代码,使用接口进行 Mock:
// 定义接口
type DataStore interface {
Get(key string) (string, error)
Set(key, value string) error
}
// 业务逻辑
func ProcessData(store DataStore, key string) error {
value, err := store.Get(key)
if err != nil {
return err
}
// 处理数据...
return store.Set(key, value)
}
// Mock 实现
type MockStore struct {
data map[string]string
}
func (m *MockStore) Get(key string) (string, error) {
if v, ok := m.data[key]; ok {
return v, nil
}
return "", errors.New("not found")
}
func (m *MockStore) Set(key, value string) error {
m.data[key] = value
return nil
}
// 测试
func TestProcessData(t *testing.T) {
mock := &MockStore{
data: map[string]string{"key1": "value1"},
}
err := ProcessData(mock, "key1")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}9.3 测试技巧总结
| 技巧 | 说明 |
|---|---|
| AAA 模式 | Arrange(准备)→ Act(执行)→ Assert(断言) |
| 单一职责 | 每个测试只测试一个功能点 |
| 独立性 | 测试之间不应有依赖关系 |
| 可重复性 | 测试结果应该是可重复的 |
| 快速执行 | 单元测试应该快速完成 |
| 清晰命名 | 测试名称应清楚表达测试意图 |
9.4 常用断言方法
// 基本断言
if got != want {
t.Errorf("got %v, want %v", got, want)
}
// 切片、map 比较
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
// 错误断言
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 空值断言
if result == nil {
t.Error("result should not be nil")
}
// 布尔断言
if !condition {
t.Error("condition should be true")
}10. 总结
10.1 测试类型对比
| 测试类型 | 函数前缀 | 参数 | 用途 |
|---|---|---|---|
| 单元测试 | Test | *testing.T | 验证功能正确性 |
| 基准测试 | Benchmark | *testing.B | 测试性能 |
| 示例函数 | Example | 无 | 文档和示例 |
10.2 常用命令速查
# 基本测试命令
go test # 运行当前目录测试
go test ./... # 运行所有包的测试
go test -v # 显示详细输出
go test -run TestName # 运行指定测试
go test -run TestName/SubTest # 运行指定子测试
# 覆盖率
go test -cover # 显示覆盖率
go test -coverprofile=c.out # 生成覆盖率文件
go tool cover -html=c.out # 查看 HTML 覆盖率报告
# 基准测试
go test -bench=. # 运行所有基准测试
go test -bench=BenchName # 运行指定基准测试
go test -bench=. -benchmem # 显示内存分配
go test -bench=. -cpuprofile=cpu.out # 生成 CPU profile
# 其他选项
go test -race # 检测竞态条件
go test -timeout 30s # 设置超时时间
go test -parallel 4 # 设置并行数
go test -count=10 # 重复运行 10 次10.3 学习资源
11. 练习题
练习 1:基础测试
为以下函数编写单元测试:
// 判断是否为回文串
func IsPalindrome(s string) bool {
runes := []rune(s)
for i := 0; i < len(runes)/2; i++ {
if runes[i] != runes[len(runes)-1-i] {
return false
}
}
return true
}练习 2:表格驱动测试
为以下函数编写表格驱动测试:
// 计算阶乘
func Factorial(n int) int {
if n < 0 {
return -1
}
if n == 0 {
return 1
}
return n * Factorial(n-1)
}练习 3:基准测试
对比以下两种字符串拼接方式的性能:
// 方式1:使用 + 拼接
func ConcatWithPlus(strs []string) string {
result := ""
for _, s := range strs {
result += s
}
return result
}
// 方式2:使用 strings.Builder
func ConcatWithBuilder(strs []string) string {
var builder strings.Builder
for _, s := range strs {
builder.WriteString(s)
}
return builder.String()
}