概述
本文将介绍如何使用 Go 语言编写高质量的单元测试,并解释相关的最佳实践。我们将通过一个完整的示例项目来演示如何编写测试、为什么这样写以及如何在日常开发中应用这些技术。
单元测试基础
测试文件命名
在 Go 中,测试文件需要以 _test.go
结尾,通常与要测试的代码文件放在同一个包中。例如:
calculator.go # 实现代码
calculator_test.go # 测试代码
测试函数签名
测试函数必须以 Test
开头,后接首字母大写的名称,并接受一个 *testing.T
参数:
func TestAdd(t *testing.T) {
// 测试代码
}
示例项目:计算器
让我们以一个简单的计算器实现为例,展示如何编写全面的单元测试。
实现代码 (calculator.go)
package calculator
// Add 返回两个整数的和
func Add(a, b int) int {
return a + b
}
// Subtract 返回两个整数的差
func Subtract(a, b int) int {
return a - b
}
// Multiply 返回两个整数的乘积
func Multiply(a, b int) int {
return a * b
}
// Divide 返回两个整数的除法结果和可能的错误
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivisionByZero
}
return a / b, nil
}
// ErrDivisionByZero 是除零错误
var ErrDivisionByZero = errors.New("division by zero")
测试代码 (calculator_test.go)
package calculator
import (
"testing"
)
// TestAdd 测试加法函数
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -1, -1, -2},
{"mixed numbers", -1, 1, 0},
{"zero values", 0, 0, 0},
}
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)
}
})
}
}
// TestSubtract 测试减法函数
func TestSubtract(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive result", 5, 3, 2},
{"negative result", 3, 5, -2},
{"zero result", 5, 5, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Subtract(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Subtract(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
}
})
}
}
// TestMultiply 测试乘法函数
func TestMultiply(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 6},
{"negative numbers", -2, -3, 6},
{"mixed numbers", -2, 3, -6},
{"zero values", 0, 5, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Multiply(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Multiply(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
}
})
}
}
// TestDivide 测试除法函数
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
expectError bool
}{
{"normal division", 6, 3, 2, false},
{"division with remainder", 5, 2, 2, false},
{"division by zero", 1, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Divide(tt.a, tt.b)
if tt.expectError {
if err == nil {
t.Errorf("Divide(%d, %d) expected error but got none", tt.a, tt.b)
}
if err != ErrDivisionByZero {
t.Errorf("Divide(%d, %d) expected error %v but got %v", tt.a, tt.b, ErrDivisionByZero, err)
}
} else {
if err != nil {
t.Errorf("Divide(%d, %d) unexpected error: %v", tt.a, tt.b, err)
}
if result != tt.expected {
t.Errorf("Divide(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
}
}
})
}
}
为什么这样写测试?
1. 表格驱动测试
我们使用了表格驱动测试(Table-Driven Tests)的方法,这种方法有多个优点:
- 清晰:所有测试用例集中在一处,易于阅读和维护
- 简洁:避免了重复的测试代码
- 可扩展:添加新测试用例只需在表格中添加一行
- 一致性:所有测试用例使用相同的验证逻辑
2. 子测试 (t.Run)
使用 t.Run()
创建子测试有以下好处:
- 更好的测试输出:每个子测试有独立的名称,失败时能快速定位
- 并行执行:可以单独控制子测试的并行执行
- 独立运行:可以使用
-run
标志单独运行特定子测试
3. 全面的测试用例
我们考虑了各种边界情况:
- 正数、负数、零的运算
- 错误处理(如除零错误)
- 正常情况和边缘情况
日常使用建议
1. 测试覆盖率
使用 Go 内置的工具检查测试覆盖率:
go test -cover
生成 HTML 覆盖率报告:
go test -coverprofile=coverage.out
go tool cover -html=coverage.out
2. 测试命名
- 测试函数名:
Test
+ 被测试函数名(首字母大写) - 测试用例名:描述测试场景,如 “negative numbers”、“division by zero”
3. 错误信息
提供有用的错误信息,包括:
- 输入值
- 实际结果
- 期望结果
4. 基准测试
对于性能敏感的代码,可以添加基准测试:
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(1, 2)
}
}
运行基准测试:
go test -bench=.
5. 示例代码
Go 还支持示例代码测试,这些示例会出现在文档中:
func ExampleAdd() {
sum := Add(1, 2)
fmt.Println(sum)
// Output: 3
}
高级技巧
1. 测试辅助函数
对于重复的测试逻辑,可以提取为辅助函数:
func assertEqual(t *testing.T, a, b int) {
t.Helper()
if a != b {
t.Errorf("expected %d, got %d", a, b)
}
}
// 在测试中使用
assertEqual(t, result, expected)
注意使用 t.Helper()
标记帮助函数,这样错误报告会指向调用它的测试代码行。
2. 测试HTTP处理器
使用 net/http/httptest
包测试HTTP处理器:
func TestHandler(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
3. 使用接口模拟依赖
对于有外部依赖的代码,使用接口进行模拟:
type Database interface {
GetUser(id int) (*User, error)
}
func ProcessUser(db Database, id int) error {
// 使用db
}
// 测试中
type mockDB struct {}
func (m *mockDB) GetUser(id int) (*User, error) {
return &User{Name: "Test User"}, nil
}
func TestProcessUser(t *testing.T) {
db := &mockDB{}
err := ProcessUser(db, 1)
// 断言
}
常见问题与解决方案
1. 测试私有函数
Go 中只能测试导出的函数(首字母大写)。如果要测试私有函数:
- 将测试代码放在同一个包内(使用
package calculator
而不是package calculator_test
) - 或者将重要的私有逻辑提取到可测试的独立结构中
2. 测试随机性
对于使用随机数的函数:
- 使用固定种子进行测试
- 测试统计属性而非具体值
- 将随机源作为依赖注入
3. 测试时间相关代码
对于依赖时间的代码:
- 将时间源作为依赖注入
- 使用接口包装
time.Now()
- 在测试中提供模拟时间
总结
编写良好的单元测试是保证 Go 代码质量的关键。通过表格驱动测试、全面的测试用例和清晰的错误报告,可以创建可维护且可靠的测试套件。日常开发中应该:
- 为所有重要逻辑编写测试
- 追求合理的测试覆盖率(通常80%以上)
- 定期运行测试(可以集成到CI/CD中)
- 将测试作为设计工具,帮助改进代码结构
良好的测试不仅能捕获错误,还能作为代码的活文档,帮助其他开发者理解代码的预期行为。