返回博客

Go 测试的骨架:表驱动、子测试与行为优先

我接触 Go 测试是从「它怎么这么原始」开始的——没有 describe/it,没有 expect(x).toBe(y),断言就是 if got != want { t.Errorf(...) },连 assert 库都要自己引。但用了一段时间之后,我发现这种「原始」反而逼出了几个很好的习惯。

表驱动测试:把枚举思维固化成结构

Go 社区几乎有一条不成文的规矩:用表驱动(table-driven)写测试。具体说就是把所有测试用例提前声明成一个切片,每个元素包含输入、期望输出、以及一个人类可读的 name 字段,然后 for range 统一跑。

tests := []struct {
    name    string
    input   string
    want    *Config
    wantErr bool
}{
    {"合法配置", `{"port": 8080}`, &Config{Port: 8080}, false},
    {"非法 JSON", `{broken}`, nil, true},
    {"空输入", "", nil, true},
}
 
for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        got, err := ParseConfig(tt.input)
        // ...
    })
}

这个模式乍看是风格偏好,实际上是一种认知约束:当你强迫自己把测试用例提炼成「名字 + 输入 + 期望」的三元组时,你会自然地思考「还有哪些边界没覆盖」。正向路径、错误路径、边界值、零值——在填表的过程里一个个暴露出来。相比散落各处的独立测试函数,表驱动的结构让「遗漏了什么」一眼可见。

子测试:让 t.Run 做层次,不做装饰

Go 1.7 引入了 t.Run,让测试可以嵌套。常见的误用是把它当作注释来用——把一个大函数切成几段,每段起个名字,但本质上还是顺序执行的一坨代码。

t.Run 真正的价值在两个地方:

第一,独立失败不连带。一个子测试失败,其余子测试继续跑。相比一个大测试函数在第一次 t.Fatal 就整体退出,子测试让你能在同一次 go test 里看到所有失败,而不是修一个、跑一次、再看下一个。

第二,t.Parallel() 可以精确控制哪些可以并行。不需要整个测试套件并行,只对 IO 密集、互相独立的子测试打开这个开关——这比「全并行」和「全串行」都更接近真实需求。

行为优先:测公开 API,不测实现细节

Go 惯例里有一条容易被忽略的原则:不直接测试私有函数,通过公开 API 覆盖行为。这不只是封装的问题,而是测试稳定性的问题。

私有函数是实现细节,它的签名和逻辑随时可能因为重构而变化。如果测试锁定在私有函数上,重构就等于重写测试——测试反而成了阻力,而不是保障。

相反,如果测试覆盖的是「给这个输入,期望这个行为」,实现可以随意替换,测试依然绿色。这才是测试真正应该保障的东西。

mock 不是越多越好

Go 里 mock 很轻量——定义一个接口,写一个测试专用的假实现就够了,不需要框架。但这并不意味着应该大量 mock。真实的集成测试(用内存 SQLite、用 httptest)往往比一堆精心构造的 mock 更能发现问题,因为 mock 只能验证你假设的行为,集成测试能验证系统真实的行为。

一句话心法

测试是代码的使用说明书,写清楚「怎么用」比「覆盖率够不够」更重要。