Go 中的表驱动测试
1. 总览
当一个函数有很多分支处理时,你会想测试很多不同的输入/输出组合。如果把每个情况都写成一个单独的测试函数,测试代码会有大量重复,而且难以维护。
表驱动测试(Table-driven test)是 Go 社区最常见的测试方式。你把测试用例描述为一个数据“表格”,然后用一个循环来运行所有的测试。
读完这篇文章后,你会知道如何使用子测试来编写表驱动测试,正确地检查错误,并行运行测试,以及使用 go-cmp 来比较复杂的结构体。你也会知道什么时候不应该使用这种技术。
2. 需要测试的函数
我们使用一个函数来计算应用折扣代码后的订单金额。这个函数有足够多的分支来说明问题:
// pricing/discount.go
package pricing
import (
"errors"
"strings"
)
var (
ErrNegativeTotal = errors.New("总金额不能为负")
ErrInvalidCode = errors.New("折扣代码无效")
)
// ApplyDiscount 返回应用折扣代码后的金额(单位:元)。
func ApplyDiscount(total int, code string) (int, error) {
if total < 0 {
return 0, ErrNegativeTotal
}
switch strings.ToUpper(strings.TrimSpace(code)) {
case "":
return total, nil
case "SALE10":
return total * 90 / 100, nil
case "GIAM50K":
if total < 50000 {
return 0, nil
}
return total - 50000, nil
default:
return 0, ErrInvalidCode
}
}
该函数有五种行为需要检查。分别是:负数金额、无代码、代码 SALE10、代码 GIAM50K(两个分支)和无效代码。此外,代码还会通过 TrimSpace 和 ToUpper 进行规范化。
3. 表驱动测试的基本结构
3.1. 第一个版本
表驱动测试由三个部分组成:声明表格、遍历表格和比较结果。
// pricing/discount_test.go
package pricing
import "testing"
func TestApplyDiscount(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
}{
{name: "无代码", total: 100000, code: "", want: 100000},
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "小写 sale10 和空格", total: 100000, code: " sale10 ", want: 90000},
{name: "giam50k 正常", total: 200000, code: "GIAM50K", want: 150000},
{name: "giam50k 不足金额", total: 30000, code: "GIAM50K", want: 0},
}
for _, tc := range tests {
got, err := ApplyDiscount(tc.total, tc.code)
if err != nil {
t.Fatalf("%s: 意外错误: %v", tc.name, err)
}
if got != tc.want {
t.Errorf("%s: ApplyDiscount(%d, %q) = %d, 期望 %d",
tc.name, tc.total, tc.code, got, tc.want)
}
}
}
解释:
tests是一个包含匿名结构体的切片。每个元素是一个测试用例,包含输入(total、code)和期望的输出(want)。- 字段
name帮助你识别哪个 case 失败了。 for循环调用函数并比较结果。在 Go 中,错误信息的格式F(input) = got, want X是一个通用的约定。
要添加新的 case,你只需要在表格中添加一行。这是这种技术最大的优势。
3.2. 第一个版本的问题
上面的代码可以运行,但有两个缺点:
t.Fatalf会停止整个测试。一个 case 失败会导致后面的 case 无法运行。- 在调试时,你不能单独运行某个 case。
Subtest 解决了这两个问题。
4. 使用 t.Run 的子测试
t.Run 为每个 case 创建一个子测试。每个子测试都有自己的 *testing.T,所以 t.Fatal 只会停止当前的 case。
func TestApplyDiscount(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
}{
{name: "无代码", total: 100000, code: "", want: 100000},
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "小写 sale10 和空格", total: 100000, code: " sale10 ", want: 90000},
{name: "giam50k 正常", total: 200000, code: "GIAM50K", want: 150000},
{name: "giam50k 不足金额", total: 30000, code: "GIAM50K", want: 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ApplyDiscount(tc.total, tc.code)
if err != nil {
t.Fatalf("意外错误: %v", err)
}
if got != tc.want {
t.Errorf("ApplyDiscount(%d, %q) = %d, 期望 %d",
tc.total, tc.code, got, tc.want)
}
})
}
}
你不需要在错误信息中打印 tc.name 了,因为 Go 会自动将子测试的名称附加到输出中。使用 -v 标志运行:
go test ./pricing -v
=== RUN TestApplyDiscount
=== RUN TestApplyDiscount/无代码
=== RUN TestApplyDiscount/sale10
=== RUN TestApplyDiscount/小写_sale10_和空格
=== RUN TestApplyDiscount/giam50k_正常
=== RUN TestApplyDiscount/giam50k_不足金额
--- PASS: TestApplyDiscount (0.00s)
--- PASS: TestApplyDiscount/无代码 (0.00s)
--- PASS: TestApplyDiscount/sale10 (0.00s)
--- PASS: TestApplyDiscount/小写_sale10_和空格 (0.00s)
--- PASS: TestApplyDiscount/giam50k_正常 (0.00s)
--- PASS: TestApplyDiscount/giam50k_不足金额 (0.00s)
PASS
你可以看到名称中的空格已被替换为下划线 _。要单独运行某个 case,你需要向 -run 传入正则表达式:
go test ./pricing -run 'TestApplyDiscount/giam50k' -v
这个命令只会运行名称以 giam50k 开头的两个 case。当某个 case 失败时,输出会是这样的:
--- FAIL: TestApplyDiscount (0.00s)
--- FAIL: TestApplyDiscount/sale10 (0.00s)
discount_test.go:24: ApplyDiscount(100000, "SALE10") = 90000, 期望 85000
你会立即知道哪个 case 失败了,在哪一行,以及使用的是什么输入。
提示: 应该给 case 起短名字,避免重复,并且便于在终端中重新输入。如果两个 case 的名称相同,Go 会自动添加
#01这样的后缀,这样会让单独运行某个 case 变得困难。
5. 在表格测试中检查错误
返回错误的 case 也应该放在同一个表格中。你可以添加一个 wantErr 字段,类型为 error,然后使用 errors.Is 进行比较:
package pricing
import (
"errors"
"testing"
)
func TestApplyDiscount_Errors(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
wantErr error
}{
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "负数金额", total: -1, code: "SALE10", wantErr: ErrNegativeTotal},
{name: "不存在的代码", total: 100000, code: "TET2026", wantErr: ErrInvalidCode},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ApplyDiscount(tc.total, tc.code)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if tc.wantErr != nil {
return // 已检查错误,无需检查返回值
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
解释:
- 对于成功的 case,
wantErr的零值是nil。在这种情况下,errors.Is(nil, nil)会返回true。 - 如果函数返回了错误但你没有期望任何错误,
errors.Is(err, nil)会返回false,测试会失败。 errors.Is仍然有效,当错误被用fmt.Errorf("...: %w", err)包装时。而err == tc.wantErr这样的比较则不行。
许多代码库使用 wantErr bool 来简化操作。但这种方式只能告诉你是否有错误,而不是什么错误。如果包定义了 sentinel error,你应该优先与它进行比较。
6. 使用 t.Parallel 并行运行
当测试 case 很慢时(涉及 I/O、繁重处理),你可以让多个子测试并行运行:
func TestApplyDiscount_Parallel(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
}{
{name: "无代码", total: 100000, code: "", want: 100000},
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "giam50k", total: 200000, code: "GIAM50K", want: 150000},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := ApplyDiscount(tc.total, tc.code)
if err != nil {
t.Fatalf("意外错误: %v", err)
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
t.Parallel() 告诉测试运行器这个子测试可以与其他并行的子测试一起运行。父函数会暂停子测试,完成循环后再同时启动所有子测试。
从 Go 1.22 开始,每个 for 循环都会为 tc 创建一个新的变量,所以闭包总是会捕获正确的值。对于 Go 1.21 及以下版本,你必须复制变量:
for _, tc := range tests {
tc := tc // Go < 1.22 时需要
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// ...
})
}
如果在旧版本中遗漏了 tc := tc 这一行,所有的子测试都会使用最后一个 case。测试仍然会“通过”,但实际上只检查了一个用例。这是一个非常难以发现的 bug。检查你的 go.mod 文件中的 go 版本号。
提示: 只有在 case 不共享可变状态的情况下才使用
t.Parallel(),比如全局变量、共享临时文件或同一个数据库表。对于像ApplyDiscount这样的纯函数,收益几乎为零。不要因为习惯就盲目地添加它。
7. 使用 go-cmp 比较复杂的结构体
当函数返回的结构体包含嵌套的 slice 或 map 时,无法使用 != 操作符。而 reflect.DeepEqual 只返回 true/false,无法告诉你具体在哪里不同。库 go-cmp 解决了这个问题。将它添加到你的模块中:
go get github.com/google/go-cmp/cmp
// go.mod (摘录)
require github.com/google/go-cmp v0.6.0
假设你有一个函数来创建发票:
// pricing/invoice.go
package pricing
type Invoice struct {
Customer string
Items []string
Total int
}
func BuildInvoice(customer string, items []string, total int, code string) (Invoice, error) {
final, err := ApplyDiscount(total, code)
if err != nil {
return Invoice{}, err
}
return Invoice{Customer: customer, Items: items, Total: final}, nil
}
测试使用 cmp.Diff:
package pricing
import (
"testing"
"github.com/google/go-cmp/cmp"
)
func TestBuildInvoice(t *testing.T) {
tests := []struct {
name string
customer string
items []string
total int
code string
want Invoice
}{
{
name: "有 sale10 代码",
customer: "An",
items: []string{"ao", "mu"},
total: 200000,
code: "SALE10",
want: Invoice{Customer: "An", Items: []string{"ao", "mu"}, Total: 180000},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := BuildInvoice(tc.customer, tc.items, tc.total, tc.code)
if err != nil {
t.Fatalf("意外错误: %v", err)
}
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("BuildInvoice() 不匹配 (-want +got):\n%s", diff)
}
})
}
}
当两个值相等时,cmp.Diff 返回一个空字符串。如果不相等,你会得到一个易于阅读的 diff 输出:
BuildInvoice() 不匹配 (-want +got):
pricing.Invoice{
Customer: "An",
Items: {"ao", "mu"},
- Total: 180000,
+ Total: 200000,
}
- 表示期望的值,+ 表示实际值。在错误信息中明确标注 (-want +got) 的约定,这样读者就不用猜测了。
8. 常见错误和何时不应使用
编写表驱动测试时常见的一些错误:
- 表格因为过多条件字段而变得膨胀。 当测试结构体包含
setupMock bool、skipValidation bool或expectCall int这样的字段,然后循环体内充满了if语句时,你实际上是在测试中编写逻辑。此时应该将其拆分为多个独立的测试函数。 - 在
t.Run外使用t.Fatal。 一个 case 失败会导致所有后续的 case 无法运行。 - 用 map 替代 slice 而不理解后果。
map[string]struct{...}能避免 case 名称重复,而且随机化运行顺序,容易暴露 case 之间的隐藏依赖。不过输出的顺序不会固定。两种方式都是可以接受的,只要团队达成共识。 - 错误信息中缺少输入信息。 只写
t.Errorf("错误")会让调试者不得不打开测试代码来阅读。应该始终输出输入、实际值(got)和期望值(want)。 - 忘记边界 case。 表格让你容易看出哪些 case 还没有涵盖。检查零值、负数、空字符串以及临界值(例如,对于
GIAM50K,可以检查total: 50000)。
以下情况不应该使用表驱动测试:
- 每个 case 需要非常不同的设置,例如 mock 返回不同的数据或调用顺序不同。硬生生地把它们放在一个表格里只会让测试变得更难读。
- 你只有一个或两个 case。直接写一个测试函数会更容易理解。
- 你需要随机输入的探索。此时应该考虑 fuzzing 测试(
func FuzzXxx(f *testing.F)),从 Go 1.18 开始就有了。你可以通过f.Add用表格作为初始语料库。
9. 结论
表驱动测试让你可以把许多测试用例放在一个数据表格中,并用一个循环来运行它们。结合 t.Run,每个 case 都成为一个独立的子测试。你可以用 -run 单独运行某个 case,一个 case 的失败也不会导致其他 case 停止运行。
需要记住的要点:
- 每个 case 应该有
name、输入和期望的输出。 - 用
errors.Is和wantErr error字段来检查错误。 - 仅在真正需要时才使用
t.Parallel(),并注意 Go 1.22 之前的循环变量语义。 - 比较复杂结构体时使用
cmp.Diff。 - 当表格开始包含控制逻辑时,这是一个信号,表示你应该将测试拆分开来。