如何在Go中编写单元测试_Go testing包基础用法

写好Go单元测试的关键在于理解测试函数签名约束、规避并发与清理陷阱、严格隔离被测逻辑。测试函数须以Test开头、接收*testing.T参数;用t.Run分组时需显式拷贝循环变量;依赖外部资源时应使用t.TempDir()等机制确保作用域隔离与自动清理。

Go 的 testing 包本身足

够轻量,但写好单元测试的关键不在于会调用 go test,而在于是否理解测试函数签名约束、是否绕开了常见陷阱(比如并发写共享变量、忘记清理临时文件)、以及是否真正隔离了被测逻辑。

测试函数必须以 Test 开头且接收 *testing.T 参数

Go 测试框架只识别形如 func TestXxx(t *testing.T) 的函数。名字中的 Xxx 必须大写字母开头,否则 go test 会直接忽略它。参数类型也严格限定为 *testing.T(或 *testing.B 用于基准测试),传其他类型会导致编译失败或运行时 panic。

常见错误现象:

  • 写成 func testAdd() { ... } → 不会被执行
  • 写成 func TestAdd(t int) → 编译报错:missing argument for flag -test.testlogfile(实际是反射调用失败)
  • 在测试中启动 goroutine 但没等完成就返回 → 测试提前结束,断言失效

正确写法示例:

func TestAdd(t *testing.T) {
    result := add(2, 3)
    if result != 5 {
        t.Errorf("expected 5, got %d", result)
    }
}

使用 t.Run 分组子测试并避免变量捕获陷阱

当一组测试逻辑相似(比如不同输入组合),用 t.Run 建立子测试,既能结构化输出,又能防止闭包中复用循环变量导致所有子测试跑同一组数据。

典型问题场景:遍历测试用例切片时直接在 for range 中调用 t.Run,却把循环变量传进闭包。

错误写法(所有子测试都用最后一个用例):

for _, tc := range []struct{ a, b, want int }{
    {1, 2, 3},
    {0, 0, 0},
    {-1, 1, 0},
} {
    t.Run(fmt.Sprintf("%d+%d", tc.a, tc.b), func(t *testing.T) {
        if got := add(tc.a, tc.b); got != tc.want {
            t.Errorf("add(%d,%d) = %d, want %d", tc.a, tc.b, got, tc.want)
        }
    })
}

正确写法(显式拷贝值):

for _, tc := range []struct{ a, b, want int }{
    {1, 2, 3},
    {0, 0, 0},
    {-1, 1, 0},
} {
    tc := tc // 关键:创建局部副本
    t.Run(fmt.Sprintf("%d+%d", tc.a, tc.b), func(t *testing.T) {
        if got := add(tc.a, tc.b); got != tc.want {
            t.Errorf("add(%d,%d) = %d, want %d", tc.a, tc.b, got, tc.want)
        }
    })
}

测试依赖外部资源时务必控制作用域和清理

涉及文件、网络、数据库的测试容易污染环境或造成竞态。Go 测试中没有统一的 setup/teardown 钩子,必须手动保证每个测试独立。

关键原则:

  • 临时文件用 t.TempDir() 创建,路径自动注册清理(Go 1.16+)
  • 修改全局变量(如 os.Argslog.SetOutput)后必须恢复,否则影响后续测试
  • 开启 HTTP server 时绑定 localhost:0 让系统分配空闲端口,用 srv.URL 构造请求
  • 不要在多个测试间复用同一个 http.Client 或连接池,除非明确需要测试连接复用行为

例如模拟命令行参数:

func TestMain(m *testing.M) {
    // 保存原始 os.Args
    origArgs := os.Args
    defer func() { os.Args = origArgs }()

    // 替换为测试参数
    os.Args = []string{"cmd", "--flag=true"}
    os.Exit(m.Run())
}

真正难的不是写几个 t.Error,而是让每个测试像一个无状态函数:输入确定、副作用可控、执行顺序无关。很多人卡在测试“看起来过了”,但一加 -race 就崩,或者换个机器就失败——那通常不是测试写得少,而是没守住隔离边界。