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)
            }
        })
    }
}
testing.T 核心方法结构 TestXxx(t *testing.T) t.Error / t.Errorf 记录失败,继续执行 t.Fatal / t.Fatalf 记录失败,立即终止 t.Run(name, fn) 创建独立子测试 子测试 1 子测试 2 💡 选择策略: 前置条件失败 → t.Fatal;收集多个断言 → t.Error;组织多组用例 → t.Run + 表驱动

4.4 三者的协同使用

在实际的错误码断言测试中,Test 签名、t.Error/t.Fatal 和 t.Run 三者通常协同工作:

  1. Test 签名保证测试函数被 go test 正确识别。
  2. t.Run 将不同错误码场景组织为独立的子测试,便于定位和选择性执行。
  3. t.Fatal 在错误码断言失败时立即终止当前子测试,避免后续无效操作。
  4. 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,允许在一次运行中暴露多个消息格式问题。