Go 并发编程 - 第16课:单元测试
所属模块:工程化
难度:初级
标签:test, testing, assert, subtest
上一课:包与模块
下一课:基准测试与性能分析
1. 学习目标
完成本课后,你将能够:
- 掌握 Go 标准库
testing包编写基本单元测试的方法。 - 使用表驱动测试(Table-Driven Tests)高效组织多组测试用例。
- 利用子测试(Subtests)对测试进行更精细的组织和控制。
- 理解并使用常见的断言方式(包括标准库和第三方库)。
- 遵循 Go 项目的最佳实践来组织和运行测试。
2. 核心概念
2.1 什么是单元测试?
单元测试就像是为你的代码函数配备的一个“自动检查作业的机器人”。你提前写好一些测试用例(给定输入和预期输出),然后让这个机器人自动运行你的函数,检查实际输出是否与预期一致。这能帮助你快速发现 Bug,放心地重构代码。
2.2 Go 的测试利器:testing 包
Go 语言内置了强大的测试工具。你只需要遵循简单的规则,就能编写测试:
- 测试文件:必须以
_test.go结尾,例如mathutils_test.go。 - 测试函数:函数名必须以
Test开头,后接一个大写字母,例如TestAdd。它接收一个参数t *testing.T。 - 断言:使用
t.Errorf,t.Fatalf等方法报告测试失败。 - 运行:在项目目录下,执行
go test命令即可。
2.3 表驱动测试
这是 Go 社区极力推荐的一种测试模式。它将测试输入和预期输出组织在一个“表”(通常是切片或映射)中,然后用循环遍历这个表,为每一行数据运行一次测试逻辑。这使得添加新用例变得极其简单。
2.4 子测试
通过 t.Run(name, func(t *testing.T){...}),你可以在一个主测试函数下创建具有独立命名和生命周期的子测试。这对于测试一个功能的不同场景、或者共享公共设置/清理逻辑非常有用。
2.5 断言库
标准库的 t.Errorf 提供了基本断言,但有时需要更丰富的断言方式(如集合比较、深度比较)。常用的第三方库是 github.com/stretchr/testify/assert,它能让你写出更简洁、更具表达力的断言。
3. 代码示例
3.1 待测试的代码 (mathutils.go)
假设我们有一些简单的数学工具函数。
package mathutils
import (
"errors"
"math"
)
// Add 返回两个整数的和
func Add(a, b int) int {
return a + b
}
// Divide 返回两个浮点数的商,如果除数为零则返回错误
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
// IsPrime 检查一个大于1的正整数是否为素数
func IsPrime(n int) bool {
if n <= 1 {
return false
}
if n <= 3 {
return true
}
if n%2 == 0 || n%3 == 0 {
return false
}
for i := 5; i*i <= n; i += 6 {
if n%i == 0 || n%(i+2) == 0 {
return false
}
}
return true
}
// Sqrt 计算平方根,对负数返回错误
func Sqrt(x float64) (float64, error) {
if x < 0 {
return 0, errors.New("square root of negative number")
}
return math.Sqrt(x), nil
}
3.2 测试文件 (mathutils_test.go)
package mathutils
import (
"testing"
"math"
// 可选:如果想使用 testify 库,取消下面的注释并执行 `go get github.com/stretchr/testify`
// "github.com/stretchr/testify/assert"
)
// TestAdd 测试 Add 函数的基础用例
func TestAdd(t *testing.T) {
// 使用标准库进行断言
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d; want 5", result)
}
result = Add(-1, 1)
if result != 0 {
t.Errorf("Add(-1, 1) = %d; want 0", result)
}
}
// TestDivide_StandardLib 使用标准库和表驱动测试测试 Divide 函数
func TestDivide_StandardLib(t *testing.T) {
// 定义测试表:每个元素包含输入、期望结果和期望错误
tests := []struct {
name string // 测试用例名称
a, b float64 // 输入
expected float64 // 期望结果
wantErr bool // 是否期望返回错误
}{
{"正数除法", 10, 2, 5.0, false},
{"负数结果", 10, -2, -5.0, false},
{"除以零", 5, 0, 0, true},
{"小数运算", 7.5, 2.5, 3.0, false},
}
for _, tt := range tests {
// 为每个测试用例运行子测试
t.Run(tt.name, func(t *testing.T) {
result, err := Divide(tt.a, tt.b)
// 检查错误是否符合预期
if (err != nil) != tt.wantErr {
t.Errorf("Divide(%v, %v) error = %v, wantErr %v", tt.a, tt.b, err, tt.wantErr)
return
}
// 检查结果是否符合预期(使用一个很小的误差范围来处理浮点数精度)
if !tt.wantErr && math.Abs(result-tt.expected) > 1e-6 {
t.Errorf("Divide(%v, %v) = %v, want %v", tt.a, tt.b, result, tt.expected)
}
})
}
}
// TestDivide_Testify 如果使用 testify 库,断言会更简洁
// func TestDivide_Testify(t *testing.T) {
// tests := []struct {
// name string
// a, b float64
// expected float64
// wantErr bool
// }{
// {"正常除法", 10, 2, 5.0, false},
// {"错误情况", 10, 0, 0, true},
// }
//
// for _, tt := range tests {
// t.Run(tt.name, func(t *testing.T) {
// result, err := Divide(tt.a, tt.b)
// if tt.wantErr {
// assert.Error(t, err)
// } else {
// assert.NoError(t, err)
// assert.InDelta(t, tt.expected, result, 1e-6, "结果应近似相等")
// }
// })
// }
// }
// TestIsPrime 使用表驱动测试素数函数
func TestIsPrime(t *testing.T) {
primeTests := []struct {
input int
expected bool
}{
{-5, false},
{0, false},
{1, false},
{2, true},
{3, true},
{4, false},
{5, true},
{6, false},
{7, true},
{8, false},
{9, false},
{10, false},
{11, true},
{13, true},
{97, true},
{100, false},
}
for _, tt := range primeTests {
t.Run("IsPrime_"+string(rune(tt.input)), func(t *testing.T) {
result := IsPrime(tt.input)
if result != tt.expected {
t.Errorf("IsPrime(%d) = %v; want %v", tt.input, result, tt.expected)
}
})
}
}
// TestSqrt 测试包含错误路径的函数
func TestSqrt(t *testing.T) {
// 测试正常路径
result, err := Sqrt(16.0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if math.Abs(result-4.0) > 1e-6 {
t.Errorf("Sqrt(16) = %f; want 4.0", result)
}
// 测试错误路径
_, err = Sqrt(-4.0)
if err == nil {
t.Error("expected error for Sqrt of negative number, but got nil")
}
}
4. 实践练习
练习 1: 为 Multiply 函数编写测试
- 在
mathutils.go文件中添加一个函数Multiply(a, b int) int,返回两个整数的乘积。 - 在
mathutils_test.go中为Multiply函数编写测试TestMultiply。 - 至少包含 2 个测试用例(例如
Multiply(3, 4)和Multiply(-2, 5))。 - 运行
go test -run TestMultiply -v查看结果。
练习 2: 使用表驱动测试 Max 函数
- 编写函数
Max(a, b int) int,返回两个整数中的较大值。 - 要求:使用表驱动测试的方式为其编写测试
TestMax。 - 测试表至少包含 4 种情况:
a > b,a < b,a == b,a或b为负数。 - 预期:所有用例通过。
练习 3: 为 ParseAge 函数编写测试
- 编写一个函数
ParseAge(s string) (int, error)。- 尝试将字符串
s解析为整数年龄。 - 如果解析成功且年龄在 0 到 150 之间,返回该年龄和
nil错误。 - 如果解析失败,或年龄不在范围内,返回
0和一个具体的错误信息(例如"invalid age format"或"age out of range (0-150)")。
- 尝试将字符串
- 使用表驱动测试和子测试,为
ParseAge编写全面的测试TestParseAge。 - 测试表应覆盖以下情况:
- 正常的年龄字符串(如
"25") - 无效格式(如
"abc","25.5") - 年龄为边界值(
"0","150") - 年龄越界(
"-1","151") - 空字符串
- 正常的年龄字符串(如
- 预期:所有用例通过。
5. 常见错误
- 测试文件名错误:文件名不以
_test.go结尾,导致go test无法识别。 - 测试函数签名错误:函数名不以
Test开头,或没有接收*testing.T参数。 - 逻辑错误导致测试永真:断言条件写反,或者测试本身的逻辑有误,导致即使函数行为错误,测试也会通过(“绿灯假象”)。
- 依赖外部状态:测试依赖于外部数据库、网络服务或系统时间,导致测试不可重复、脆弱。应该尽量使用 mock 或 stub。
- 不检查错误:对于返回
error的函数,测试时忘记检查错误是否为nil,或者检查了错误但逻辑不正确。 - 浮点数比较错误:直接使用
==比较浮点数,由于精度问题可能导致测试不稳定。应使用一个小的误差范围(delta)进行比较。 - 运行全部测试却以为只运行了一个:在项目根目录运行
go test ./...会运行所有包的测试。使用go test -run TestSpecific来运行特定的测试函数。
6. 小结
本课我们学习了 Go 语言中单元测试的核心知识:
- 测试基础:遵循
_test.go文件名和TestXxx函数名的约定,使用testing包提供的方法进行断言。 - 表驱动测试:将测试用例组织成表格形式,通过循环高效运行,这是 Go 社区的最佳实践,易于维护和扩展。
- 子测试:使用
t.Run()为相关测试用例创建具有描述性名称的子测试,使测试输出更清晰,也便于调试和选择性运行。 - 断言工具:标准库
t.Errorf够用,但第三方库如testify/assert能提供更强大、更简洁的断言能力。 - 测试哲学:好的单元测试应该是独立的、可重复的、快速的,并且能清晰地验证函数在各种边界条件下的行为。
掌握单元测试是成为一名优秀工程师的必经之路。它不仅是一种保障代码质量的工具,更是一种驱动你写出更清晰、更模块化代码的设计思想。现在就去为你的代码编写测试吧!
练习编辑器
go
Loading...