写好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.Args、log.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 就崩,或者换个机器就失败——那通常不是测试写得少,而是没守住隔离边界。










