Go 表驱动测试实战:用 t.Run 子测试组织可维护的单元测试

Go 表驱动测试实战:用 t.Run 子测试组织可维护的单元测试

你给一个函数写单测,第一版是复制粘贴:测输入 A 断言一段,测输入 B 再抄一遍断言,测边界值又抄一遍。三个用例还好,等长到十几个,某天函数签名改了,你得挨个改十几段几乎一样的代码。更糟的是,一个断言 t.Fatal 挂掉,后面的用例根本没跑,你不知道到底错了几个。Go 社区的标准答案是表驱动测试 :把用例做成一张表,循环跑,配合 t.Run 拆成独立子测试。这篇手把手带你从「复制粘贴」重构到「一张表搞定」。

被测函数

拿一个简单的场景:把秒数格式化成人类可读的时长。

go 复制代码
package timeutil

import "fmt"

// FormatDuration 把秒数格式化成 "1h2m3s" 这样的字符串
func FormatDuration(sec int) (string, error) {
    if sec < 0 {
        return "", fmt.Errorf("秒数不能为负: %d", sec)
    }
    h, m, s := sec/3600, (sec%3600)/60, sec%60
    return fmt.Sprintf("%dh%dm%ds", h, m, s), nil
}

朴素写法:复制粘贴,又臭又长

第一版新手写法,每个用例抄一遍:

go 复制代码
func TestFormatDuration_Naive(t *testing.T) {
    got, _ := FormatDuration(0)
    if got != "0h0m0s" {
        t.Fatalf("0 秒: 期望 0h0m0s, 得到 %s", got)
    }

    got, _ = FormatDuration(3661)
    if got != "1h1m1s" {
        t.Fatalf("3661 秒: 期望 1h1m1s, 得到 %s", got)
    }

    _, err := FormatDuration(-1)
    if err == nil {
        t.Fatal("负数应该报错")
    }
}

三个问题:断言逻辑重复三遍;t.Fatalf 一旦触发,后面的用例直接不跑了;加用例只能继续往下抄。

表驱动重构:用例是数据,断言只写一次

核心思路是把「输入 + 期望输出」抽成一个结构体切片(就是那张「表」),断言逻辑写一遍,循环套用:

go 复制代码
func TestFormatDuration(t *testing.T) {
    // 这张表就是全部用例,加用例 = 加一行
    tests := []struct {
        name    string // 用例名,出错时能一眼看出是哪个
        input   int
        want    string
        wantErr bool
    }{
        {name: "零秒", input: 0, want: "0h0m0s", wantErr: false},
        {name: "一分钟", input: 60, want: "0h1m0s", wantErr: false},
        {name: "时分秒齐全", input: 3661, want: "1h1m1s", wantErr: false},
        {name: "负数报错", input: -1, want: "", wantErr: true},
    }

    for _, tt := range tests {
        got, err := FormatDuration(tt.input)

        if (err != nil) != tt.wantErr {
            t.Errorf("%s: 是否报错 = %v, 期望 wantErr = %v", tt.name, err != nil, tt.wantErr)
            continue
        }
        if got != tt.want {
            t.Errorf("%s: 得到 %q, 期望 %q", tt.name, got, tt.want)
        }
    }
}

注意这里用 t.Errorf 而不是 t.Fatalf------Errorf 标记失败但继续跑,所以一次能看到所有失败的用例,而不是碰到第一个就停。加新用例只需在表里加一行,断言逻辑一个字都不用动。

升级:t.Run 拆成独立子测试

上面的版本还有个小缺点:所有用例挤在一个 TestFormatDuration 里,测试输出看不清哪个子用例过了、哪个挂了,也没法单独跑某一个。用 t.Run 给每个用例开一个子测试就解决了:

go 复制代码
func TestFormatDuration(t *testing.T) {
    tests := []struct {
        name    string
        input   int
        want    string
        wantErr bool
    }{
        {name: "零秒", input: 0, want: "0h0m0s"},
        {name: "一分钟", input: 60, want: "0h1m0s"},
        {name: "时分秒齐全", input: 3661, want: "1h1m1s"},
        {name: "负数报错", input: -1, wantErr: true},
    }

    for _, tt := range tests {
        // 每个用例一个子测试,名字就是 tt.name
        t.Run(tt.name, func(t *testing.T) {
            got, err := FormatDuration(tt.input)
            if (err != nil) != tt.wantErr {
                t.Fatalf("是否报错 = %v, 期望 %v", err != nil, tt.wantErr)
            }
            if got != tt.want {
                t.Errorf("得到 %q, 期望 %q", got, tt.want)
            }
        })
    }
}

t.Run 的好处:

  • 输出里每个子测试独立显示,--- FAIL: TestFormatDuration/负数报错 一眼定位。
  • 可以只跑某一个:go test -run 'TestFormatDuration/时分秒齐全'
  • 子测试之间隔离,某个子测试 t.Fatalf 只终止它自己,不影响其余子测试继续跑。

跑一下看输出:

bash 复制代码
$ go test -v -run TestFormatDuration
=== RUN   TestFormatDuration
=== RUN   TestFormatDuration/零秒
=== RUN   TestFormatDuration/一分钟
=== RUN   TestFormatDuration/时分秒齐全
=== RUN   TestFormatDuration/负数报错
--- PASS: TestFormatDuration (0.00s)
    --- PASS: TestFormatDuration/零秒 (0.00s)
    --- PASS: TestFormatDuration/一分钟 (0.00s)
    --- PASS: TestFormatDuration/时分秒齐全 (0.00s)
    --- PASS: TestFormatDuration/负数报错 (0.00s)
PASS

一个必须知道的坑:循环变量与 t.Parallel

如果你想让子测试并行跑(t.Parallel()),在 Go 1.22 之前 有个经典陷阱:所有子测试闭包共享同一个循环变量 tt,并行执行时它们读到的都是循环结束后的最后一个值。老代码里你会看到这种「重新赋值」的写法来规避:

go 复制代码
for _, tt := range tests {
    tt := tt // Go 1.22 前必须这行,给每次迭代一份独立副本
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel() // 标记为并行,和其他并行子测试一起跑
        got, _ := FormatDuration(tt.input)
        if got != tt.want {
            t.Errorf("得到 %q, 期望 %q", got, tt.want)
        }
    })
}

Go 1.22 起循环变量每次迭代都是新的 ,tt := tt 这行可以删掉了。但如果你的项目还跑在老版本,或者你不确定别人的环境,留着它最保险------它无害。不加 t.Parallel() 时子测试是串行的,不受这个坑影响,但加了并行又忘了处理循环变量,就会得到「所有用例都在测最后一行数据」的诡异结果。

小结

  • 别复制粘贴断言:把用例抽成结构体切片(表),断言逻辑只写一遍,循环套用。
  • 表里给每个用例一个 name 字段,出错能一眼定位是哪个用例。
  • t.Run(tt.name, ...) 拆成子测试:输出清晰、可单独跑、子测试之间隔离。
  • 收集所有失败用 t.Errorf(继续跑),需要立即终止当前子测试才用 t.Fatalf
  • 并行子测试注意循环变量陷阱:Go 1.22 前要写 tt := tt,1.22 起可省;不确定就留着。
  • 一句话记忆:用例是数据、断言是代码,一张表 + t.Run 就是 Go 测试的标准姿势。
相关推荐
JavaPub-rodert2 小时前
Go 后台如何同时兼容 MySQL、PostgreSQL、SQLite 和 SQL Server?从 ShiyuAdmin 看 GORM 多数据库适配
数据库·mysql·postgresql·golang·javapub·王仕宇
OPEN-F4 小时前
Python进阶教程:单元测试与代码质量
开发语言·python·单元测试
用户330144867635 小时前
03. Size Class 分级机制
go
用户330144867635 小时前
04. mallocgc:分配总入口
go
程序员爱钓鱼9 小时前
Go 编程实战:函数 Function——参数、返回值与代码复用
后端·面试·go
剩下了什么16 小时前
float32 与 float64 精度陷阱:如何在 Go 中避免错误使用
开发语言·后端·golang
ttwuai1 天前
Go 后台接入 SSO 后菜单正常但接口 403,怎么排查权限链路?
开发语言·后端·golang
PC2005-cloud1 天前
Go学习笔记:基本概念与项目结构——GOPATH、Go Modules 与常用命令
笔记·学习·golang
ttwuai1 天前
Go 后台图片上传到对象存储后,预览 403/404 怎么排查?
开发语言·golang
名字还没想好☜1 天前
Go 1.23 range-over-func 迭代器实战:自定义可迭代类型、提前退出与惰性求值
开发语言·后端·golang·go·迭代器