4. 标准库 testing 基础:Test 函数签名、t.Error vs t.Fatal、子测试 t.Run
Go 语言标准库 testing 是编写单元测试、基准测试和示例函数的官方基础设施。掌握 testing.T 的核心用法,是后续实现错误码断言的前提。本节聚焦三个基础但关键的要素:Test 函数签名、t.Error 与 t.Fatal 的区别、以及子测试 t.Run。
4.1 Test 函数签名
每个测试函数必须遵循固定的签名规则,否则 go test 工具无法识别并执行它。
| 规则项 | 要求 | 示例 |
|---|---|---|
| 函数名 | 以 Test 开头,后接大写字母开头的标识符 |
TestAdd、TestUserLogin |
| 参数 | 唯一参数类型为 *testing.T |
t *testing.T |
| 返回值 | 无返回值 | func TestXxx(t *testing.T) |
| 文件命名 | 源文件以 _test.go 结尾 |
math_test.go |
常见错误:函数名写成
testAdd(小写开头)或参数类型写成 *testing.B,都会导致测试被静默跳过。
// 正确的测试函数签名
func TestDivide(t *testing.T) {
result := divide(10, 2)
if result != 5 {
t.Errorf("期望 5,得到 %d", result)
}
}
4.2 t.Error vs t.Fatal:失败报告的两种模式
t.Error 和 t.Fatal 都用于标记测试失败,但它们在执行流程上有本质区别。
| 方法 | 行为 | 后续代码是否执行 | 适用场景 |
|---|---|---|---|
t.Error(args...) |
记录失败日志,标记测试为失败 | 继续执行 | 需要收集多个失败点,或后续清理必须运行 |
t.Fatal(args...) |
记录失败日志,标记测试为失败,立即终止当前 goroutine | 停止执行 | 前置条件失败,后续断言无意义;或避免 panic |
陷阱提示:在
t.Fatal 之后如果还有 defer 注册的清理函数,它们仍然会执行。但 t.Fatal 不会执行同函数内 defer 之后的代码行。
func TestFileOpen(t *testing.T) {
f, err := os.Open("nonexistent.txt")
if err != nil {
// 文件不存在,后续所有操作都无效,使用 Fatal
t.Fatalf("打开文件失败: %v", err)
}
defer f.Close()
// 只有文件打开成功才会执行到这里
data := make([]byte, 100)
n, err := f.Read(data)
if err != nil {
t.Errorf("读取文件失败: %v", err)
}
t.Logf("成功读取 %d 字节", n)
}
最佳实践:在错误码断言场景中,如果被测函数返回了非预期的错误码,通常使用
t.Fatal 立即终止,因为后续基于正确返回值的断言已经失去意义。
4.3 子测试 t.Run:组织测试层次
t.Run 允许在单个 Test 函数内创建子测试,实现测试的层次化组织和并行控制。每个子测试拥有独立的 *testing.T 实例,可以独立运行、跳过或并行执行。
func TestUserValidation(t *testing.T) {
// 子测试:用户名验证
t.Run("username", func(t *testing.T) {
if !validateUsername("alice") {
t.Error("合法用户名被拒绝")
}
if validateUsername("ab") {
t.Error("过短用户名被接受")
}
})
// 子测试:邮箱验证
t.Run("email", func(t *testing.T) {
if !validateEmail("user@example.com") {
t.Error("合法邮箱被拒绝")
}
if validateEmail("not-email") {
t.Error("非法邮箱被接受")
}
})
}
子测试的核心优势:
- 独立报告:每个子测试的通过/失败状态单独显示在测试输出中。
- 选择性执行:通过
go test -run TestUserValidation/username只运行特定子测试。 - 表驱动测试:结合循环和
t.Run,可以优雅地组织多组输入输出。 - 并行控制:子测试内调用
t.Parallel()可实现细粒度并行。
表驱动 + 子测试模式:这是 Go 社区最推崇的测试组织方式,也是后续错误码断言课程的核心模式。
func TestDivideTable(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
wantErr bool
}{
{"positive", 10, 2, 5, false},
{"divide by zero", 10, 0, 0, true},
{"negative", -6, 3, -2, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := divide(tt.a, tt.b)
if tt.wantErr {
if err == nil {
t.Error("期望错误,但没有得到")
}
return
}
if err != nil {
t.Fatalf("不期望错误: %v", err)
}
if result != tt.expected {
t.Errorf("期望 %d,得到 %d", tt.expected, result)
}
})
}
}
4.4 三者的协同使用
在实际的错误码断言测试中,Test 签名、t.Error/t.Fatal 和 t.Run 三者通常协同工作:
- Test 签名保证测试函数被
go test正确识别。 - t.Run 将不同错误码场景组织为独立的子测试,便于定位和选择性执行。
- t.Fatal 在错误码断言失败时立即终止当前子测试,避免后续无效操作。
- t.Error 在需要收集多个错误码断言结果时使用,例如验证错误码列表的完整性。
func TestErrorCodeAssertions(t *testing.T) {
tests := []struct {
name string
input string
wantCode int
wantMsg string
}{
{"invalid input", "", 400, "input is empty"},
{"not found", "unknown", 404, "resource not found"},
{"internal error", "crash", 500, "internal server error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, msg := processRequest(tt.input)
// 错误码不匹配 → 立即终止
if code != tt.wantCode {
t.Fatalf("错误码: 期望 %d, 得到 %d", tt.wantCode, code)
}
// 错误消息不匹配 → 记录但不终止(可收集多个问题)
if msg != tt.wantMsg {
t.Errorf("错误消息: 期望 %s, 得到 %s", tt.wantMsg, msg)
}
})
}
}
实战要点:在错误码断言中,优先使用
t.Fatal 处理错误码本身不匹配的情况,因为错误码是后续所有判断的基础。错误消息的校验可以使用 t.Error,允许在一次运行中暴露多个消息格式问题。