Golang Table-Driven Testing 最佳实践

表驱动测试是 Go 里的经典写法,原因很简单:一组输入输出结构相似的测试,用表来写,代码确实更省、更稳,也更容易补边界场景。
但它也很容易被滥用。很多团队最后写出来的是另一种灾难:一张巨大的表,字段多得像配置文件,真正的测试逻辑藏在 helper 里,CI 挂了以后没人想看。
这篇文章不讲套路式模板,主要讲什么时候表驱动测试真的有价值,什么时候别硬上。
表驱动测试适合“变化的是输入”,不适合“每个场景都像一套新系统”
什么时候适合用表:
- 字符串、路径、时间等规范化逻辑
- 解析器、校验器
- 业务规则判断
- HTTP 请求解码
- 权限判断
- 一组输入输出清晰对应的纯函数
什么时候不适合:
- 每个 case 都需要完全不同的 mock
- 初始化逻辑差异很大
- 需要复杂的时序控制
- 测试重点在流程,不在参数组合
判断标准很简单:
- 如果用表能减少重复、让差异更清楚,那就用
- 如果用了表,反而把测试意图藏起来,那就拆成普通测试函数
先写小而明确的 case 结构
绝大多数时候,直接在测试函数里定义匿名结构就够了。
func SplitHostPort(addr string) (host, port string, err error) {
host, port, err = net.SplitHostPort(addr)
if err != nil {
return "", "", err
}
return host, port, nil
}
func TestSplitHostPort(t *testing.T) {
t.Parallel()
cases := []struct {
name string
addr string
wantHost string
wantPort string
wantErr bool
}{
{
name: "ipv4",
addr: "127.0.0.1:8080",
wantHost: "127.0.0.1",
wantPort: "8080",
},
{
name: "hostname",
addr: "example.com:443",
wantHost: "example.com",
wantPort: "443",
},
{
name: "missing port",
addr: "example.com",
wantErr: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
gotHost, gotPort, err := SplitHostPort(tc.addr)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotHost != tc.wantHost || gotPort != tc.wantPort {
t.Fatalf("got (%q, %q), want (%q, %q)", gotHost, gotPort, tc.wantHost, tc.wantPort)
}
})
}
}这里有几个点值得保留:
- 每个 case 都有明确名字
- 表里存的是数据,不是控制逻辑
- 断言写在测试本地,读起来不用来回跳
for循环里重新绑定tc := tc
最后这一点是老问题了:子测试闭包捕获循环变量。Go 这些年在循环变量语义上做过改进,但测试里显式重绑依然是个成本极低、可读性很高的习惯。
case 名字要像故障描述,不要像占位符
测试失败时,名字应该能直接告诉你“挂的是哪种行为”。
差的命名:
case1test2edgenormal
好的命名:
missing portempty header is rejectedtrims trailing slashadmin can read suspended account
原则是:名字描述行为,或者描述边界条件。
表要短,故事要单一
很多表驱动测试不可读,不是因为用了表,而是因为表里塞了太多东西。
坏表的典型特征:
- 十几个输入字段
- 多个 mock 预期
- 一堆布尔开关控制 helper
- 输出字段也很多
- 真正逻辑分散在 setup 和 assert helper 里
一旦出现这种趋势,就不要继续扩表了,应该拆测试。
比如,不要写一个万能的:
TestUserService
然后里面放 30 行 case、20 个字段。
更好的拆法是:
TestNormalizeEmailTestCreateUserValidationTestCreateUserConflictTestCreateUserAuditFields
表驱动测试不是为了把所有东西卷进一张表,而是为了让一类变化更清楚。
断言优先用标准库,别默认全家桶
很多 Go 项目一上来就全量引入 testify/assert,最后整个测试文件都像脚本语言。
其实大部分场景,标准库完全够用:
if got != wanterrors.Iserrors.As- 简单结构可以谨慎用
reflect.DeepEqual
你不需要为了比较两个整数专门上断言框架。
如果是复杂结构体、slice、嵌套字段,github.com/google/go-cmp/cmp 往往比一整套 assertion library 更合适,因为 diff 更直观。
例如:
func TestNormalizeLabels(t *testing.T) {
cases := []struct {
name string
in []string
want []string
}{
{
name: "trim, lower, dedupe",
in: []string{" Ops ", "ops", "SRE"},
want: []string{"ops", "sre"},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := NormalizeLabels(tc.in)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Fatalf("NormalizeLabels() mismatch (-want +got):\n%s", diff)
}
})
}
}什么时候上 cmp?很简单:当 diff 输出真的比手写错误信息更有价值的时候。
错误判断按 Go 的方式写,不要靠字符串碰运气
错误断言一般只关心几件事:
- 有没有报错
- 是不是某个特定错误
- 是否包裹了某个底层错误
例如:
var ErrDivisionByZero = errors.New("division by zero")
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivisionByZero
}
return a / b, nil
}
func TestDivide(t *testing.T) {
cases := []struct {
name string
a int
b int
want int
wantErr error
}{
{name: "positive", a: 10, b: 2, want: 5},
{name: "negative", a: -10, b: 2, want: -5},
{name: "zero divisor", a: 10, b: 0, wantErr: ErrDivisionByZero},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got, err := Divide(tc.a, tc.b)
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) {
t.Fatalf("expected error %v, got %v", tc.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %d, want %d", got, tc.want)
}
})
}
}除非错误文案本身就是契约的一部分,否则不要写 err.Error() == "..." 这种脆弱判断。
helper 要减噪音,不要藏逻辑
helper 的作用是去掉重复样板,不是把测试本身埋起来。
一个合格的 helper 通常像这样:
func mustReadFile(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return b
}有问题的 helper 通常会:
- 偷偷初始化很多状态
- 顺手做一堆和名字不匹配的断言
- 修改共享状态
- 吞掉错误返回默认值
- 必须点开三层文件才知道它干了什么
判断标准也很简单:不看 helper 实现,测试是否仍然大致可理解。
golden file 适合大输出,但前提是输出值得审
golden file 很适合这些场景:
- 生成配置文件
- 复杂 JSON 输出
- SQL 生成结果
- Markdown 渲染结果
- CLI 文本输出
例如:
func TestRenderConfig(t *testing.T) {
input := Config{
AppName: "billing",
Port: 8080,
}
got, err := RenderConfig(input)
if err != nil {
t.Fatalf("RenderConfig(): %v", err)
}
want := mustReadFile(t, "testdata/render_config.golden")
if diff := cmp.Diff(string(want), got); diff != "" {
t.Fatalf("RenderConfig() mismatch (-want +got):\n%s", diff)
}
}几点经验:
- golden file 放
testdata/ - 只用于人能审阅的稳定输出
- 时间戳、随机值、绝对路径这类不稳定字段要先归一化
- 如果输出顺序不稳定,先排序再比
golden file 的作用是提高可读性,不是逃避写断言。
fuzz test 是表驱动测试的补充,不是替代
表驱动测试适合已知例子,fuzz 更适合找你没想到的奇怪输入。
解析器、解码器、规范化逻辑,通常都值得加 fuzz。
例如:
func FuzzSplitHostPort(f *testing.F) {
seeds := []string{
"127.0.0.1:80",
"example.com:443",
"[::1]:8080",
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, addr string) {
host, port, err := SplitHostPort(addr)
if err != nil {
return
}
if host == "" {
t.Fatalf("host should not be empty for valid address %q", addr)
}
if port == "" {
t.Fatalf("port should not be empty for valid address %q", addr)
}
})
}比较好的做法是:先把表驱动测试里的典型样例拿来当 seed,再让 fuzz 往外扩。
benchmark 要测清楚一件事,不要把测试逻辑掺进去
网上很多 benchmark 示例其实不太靠谱。
常见问题:
- 在热路径里做断言
- 把
b.Run套在for i := 0; i < b.N; i++里面 - 每轮都重复做昂贵初始化,却没说明这是不是测量目标
- 明明 setup 很重,却不做
b.ResetTimer() - 用完全脱离真实场景的玩具输入测性能
一个更像样的 benchmark 应该像这样:
func BenchmarkNormalizeLabels(b *testing.B) {
input := []string{" Ops ", "ops", "SRE", "Platform", "platform"}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = NormalizeLabels(input)
}
}如果要测不同输入规模或不同路径,用 sub-benchmark:
func BenchmarkParseID(b *testing.B) {
cases := []struct {
name string
input string
}{
{name: "short", input: "usr_123"},
{name: "long", input: "usr_12345678901234567890"},
}
for _, tc := range cases {
tc := tc
b.Run(tc.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = ParseID(tc.input)
}
})
}
}benchmark 要测的是代码性能,不是测试框架用法。
并行测试很好用,但共享状态会立刻报复你
t.Parallel() 很适合提速,也很适合暴露测试之间的隐性耦合。
适合并行的测试通常满足:
- 不修改全局变量
- 不依赖时间顺序
- 不共享端口、环境变量、临时文件
- 不依赖单例状态
子测试并行时,记得:
- 重新绑定循环变量
- case 数据尽量不可变
例如:
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := CanonicalizePath(tc.input)
if got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}另外,t.Setenv 会修改当前进程环境。和并行测试混用时要特别小心。
fixture 要小,但要像真的
测试数据最好满足两个条件:
- 足够真实,能覆盖真实 bug
- 足够小,读的人能快速理解
差的 fixture 往往是:
- 从生产复制一大坨 JSON
- 几百个字段,真正有用的只有三个
- mock 返回现实里根本不可能出现的状态
- 业务规则变了,fixture 还停在旧世界
更靠谱的 fixture:
- 最小化,但不失真
- 名字能反映用途
- 尽量靠近测试
- 业务规则更新时同步更新
如果你的“合法用户” fixture 连当前线上必填字段都没有,那测试其实已经在自欺欺人。
不要为了“高级”写成巨型泛型表
Go 泛型可以用来抽公共测试工具,但不是每个测试都该抽成 TestCase[P, W]。
很多时候,直接写业务语义明确的结构反而更清楚:
cases := []struct {
name string
role string
resource string
action string
wantAllow bool
}{
// ...
}这通常比 Params、Want 这种抽象字段更容易读,尤其是业务测试。
泛型该用在“真正跨多个地方复用”的场景,不要为了看起来高级而牺牲可读性。
HTTP handler 测试很适合表驱动
只要请求构造方式基本一致,输入变化主要体现在 body、header、auth 上,表驱动通常很好用。
例如:
func TestCreateUserHandler(t *testing.T) {
cases := []struct {
name string
body string
wantStatus int
}{
{
name: "valid request",
body: `{"email":"[email protected]","name":"Dev"}`,
wantStatus: http.StatusCreated,
},
{
name: "invalid email",
body: `{"email":"not-an-email","name":"Dev"}`,
wantStatus: http.StatusUnprocessableEntity,
},
{
name: "malformed json",
body: `{"email":"[email protected]"`,
wantStatus: http.StatusBadRequest,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler := http.HandlerFunc(CreateUserHandler)
handler.ServeHTTP(rr, req)
if rr.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d, body = %s", rr.Code, tc.wantStatus, rr.Body.String())
}
})
}
}这种写法的好处是:变化点在表里,HTTP 流程在测试体里,一眼能看懂。
一份够用的检查清单
表驱动测试写得健康时,通常能回答“是”的问题有这些:
- 这张表真的减少了重复,而不是隐藏意图吗?
- case 名字在 CI 里有辨识度吗?
- 表的长度在一次阅读内能消化吗?
- 断言是不是就在测试附近?
- 子测试前有没有重新绑定循环变量?
- 错误判断有没有用
errors.Is/errors.As? - helper 是不是小而诚实?
- golden file 只用于值得审阅的大输出吗?
- fuzz 是否用在解析、规范化这类值得随机探索的逻辑上?
- benchmark 测的是实际性能问题吗?
表驱动测试只是工具,不是姿势。能把重复结构压平、让差异更清楚时,它非常好用;一旦表比被测代码还难读,就该停手了。