4、表格驱动测试:让测试代码更优雅
说实话,我刚写Go测试那会儿,每个测试用例都是单独写一个函数。测个加法函数,写了七八个TestAdd_WithPositiveNumbers、TestAdd_WithNegativeNumbers……代码又臭又长。后来看到Go标准库里的表格驱动测试,我才恍然大悟——原来测试可以这么清爽。
什么是表格驱动测试
表格驱动测试,说白了就是把测试用例组织成一张「表格」。每个用例包含输入和期望输出,然后统一遍历执行。这样做的好处很明显:
- 减少重复代码:测试逻辑写一次就够了
- 新增用例零成本:往表格里加一行就行
- 可读性极强:一眼就能看到所有测试场景
我在项目中遇到过一位同事,他写测试喜欢复制粘贴。测一个函数,复制了20遍几乎一样的代码,只是参数不同。后来我给他看了表格驱动测试,他当场就重构了——代码从300行缩到了50行。
定义测试用例结构体
先看一个最简单的例子。假设我们要测试一个加法函数:
func Add(a, b int) int {
return a + b
}
传统的测试写法是这样的:
func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Errorf("Add(1, 2) = %d, want %d", Add(1, 2), 3)
}
if Add(-1, 1) != 0 {
t.Errorf("Add(-1, 1) = %d, want %d", Add(-1, 1), 0)
}
if Add(0, 0) != 0 {
t.Errorf("Add(0, 0) = %d, want %d", Add(0, 0), 0)
}
}
嗯,这里要注意——如果我有20个用例,这代码得膨胀成什么样?
用表格驱动测试改写一下:
func TestAdd(t *testing.T) {
// 定义测试用例结构体
tests := []struct {
name string
a int
b int
want int
}{
{name: "正数相加", a: 1, b: 2, want: 3},
{name: "正负数相加", a: -1, b: 1, want: 0},
{name: "零值相加", a: 0, b: 0, want: 0},
{name: "大数相加", a: 1000000, b: 2000000, want: 3000000},
}
// 遍历测试用例执行
for _, tt := range tests {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("%s: Add(%d, %d) = %d, want %d",
tt.name, tt.a, tt.b, got, tt.want)
}
}
}
核心要点:测试用例结构体通常包含name(用例名称)、输入参数和期望输出。结构体字段可以根据被测函数灵活定义。
遍历测试用例执行
遍历执行这部分,我个人习惯用for range循环。每个用例执行完,如果失败就打印错误信息。但这里有个坑——一旦某个用例失败,后面的用例还会继续执行。这其实是好事,因为你能看到所有失败的用例,而不是卡在第一个就停了。
我曾经踩过一个坑:测试数据库操作时,用例之间有依赖关系。第一个用例插入数据,第二个用例查询数据。结果第一个用例失败了,第二个用例也跟着报错。排查了半天才发现是测试用例的顺序问题。后来我学乖了——表格驱动测试的每个用例应该是独立的,不要互相依赖。
结合子测试(t.Run)进行精细化测试
上面的写法有个小问题:如果某个用例失败了,你只知道它失败了,但不知道具体是哪个。虽然我们在错误信息里打印了name,但Go的测试框架并不会把它当作独立的测试项。
这时候就该t.Run出场了。它能把每个用例变成独立的子测试:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a int
b int
want int
}{
{name: "正数相加", a: 1, b: 2, want: 3},
{name: "正负数相加", a: -1, b: 1, want: 0},
{name: "零值相加", a: 0, b: 0, want: 0},
}
for _, tt := range tests {
// 使用 t.Run 创建子测试
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d",
tt.a, tt.b, got, tt.want)
}
})
}
}
运行结果会变成这样:
=== RUN TestAdd
=== RUN TestAdd/正数相加
=== RUN TestAdd/正负数相加
=== RUN TestAdd/零值相加
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/正数相加 (0.00s)
--- PASS: TestAdd/正负数相加 (0.00s)
--- PASS: TestAdd/零值相加 (0.00s)
你想想看,如果某个用例失败了,你能直接看到是哪个子测试出了问题。而且还能单独运行某个子测试:
go test -run TestAdd/正数相加
小技巧:子测试的名称最好用有意义的字符串,不要用数字索引。这样在日志里一眼就能看出是哪个场景出了问题。
更复杂的场景:多返回值测试
实际项目中,函数往往有多个返回值。比如一个除法函数,既要返回结果,也要返回错误:
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("除数不能为0")
}
return a / b, nil
}
测试用例结构体可以这样设计:
func TestDivide(t *testing.T) {
tests := []struct {
name string
a int
b int
want int
wantErr bool
}{
{name: "正常除法", a: 10, b: 2, want: 5, wantErr: false},
{name: "除数为0", a: 10, b: 0, want: 0, wantErr: true},
{name: "结果为0", a: 0, b: 5, want: 0, wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if tt.wantErr {
if err == nil {
t.Errorf("期望错误,但没有得到错误")
}
return
}
if err != nil {
t.Errorf("不期望错误,但得到: %v", err)
}
if got != tt.want {
t.Errorf("Divide(%d, %d) = %d, want %d",
tt.a, tt.b, got, tt.want)
}
})
}
}
注意:测试错误场景时,记得用return提前退出。否则如果got是零值,后面的断言可能会误报。
表格驱动测试的最佳实践
总结一下我这些年用表格驱动测试的心得:
- 用例要独立:每个用例不依赖其他用例的执行结果
- 命名要有意义:用中文或英文描述清楚测试场景
- 覆盖边界值:空值、零值、最大值、最小值都要测到
- 错误场景也要测:别只测正常流程,异常流程更重要
- 善用子测试:
t.Run让测试报告更清晰
我曾经接手过一个遗留项目,测试覆盖率号称80%,但全是重复的、硬编码的测试用例。我花了一周时间全部改成表格驱动测试,代码量减少了60%,覆盖率反而提升到了95%。说白了,表格驱动测试不只是让代码变短,更重要的是让测试意图变得清晰。
下一章我们会聊测试覆盖率——怎么衡量你的测试够不够好,以及常见的覆盖率陷阱。到时候见。