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. 第一个版本的问题

上面的代码可以运行,但有两个缺点:

  1. t.Fatalf 会停止整个测试。一个 case 失败会导致后面的 case 无法运行。
  2. 在调试时,你不能单独运行某个 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。
  • 当表格开始包含控制逻辑时,这是一个信号,表示你应该将测试拆分开来。
golangtestingtable-driven testunit test