目录

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 名字要像故障描述,不要像占位符

测试失败时,名字应该能直接告诉你“挂的是哪种行为”。

差的命名:

  • case1
  • test2
  • edge
  • normal

好的命名:

  • missing port
  • empty header is rejected
  • trims trailing slash
  • admin can read suspended account

原则是:名字描述行为,或者描述边界条件。

表要短,故事要单一

很多表驱动测试不可读,不是因为用了表,而是因为表里塞了太多东西。

坏表的典型特征:

  • 十几个输入字段
  • 多个 mock 预期
  • 一堆布尔开关控制 helper
  • 输出字段也很多
  • 真正逻辑分散在 setup 和 assert helper 里

一旦出现这种趋势,就不要继续扩表了,应该拆测试。

比如,不要写一个万能的:

  • TestUserService

然后里面放 30 行 case、20 个字段。

更好的拆法是:

  • TestNormalizeEmail
  • TestCreateUserValidation
  • TestCreateUserConflict
  • TestCreateUserAuditFields

表驱动测试不是为了把所有东西卷进一张表,而是为了让一类变化更清楚。

断言优先用标准库,别默认全家桶

很多 Go 项目一上来就全量引入 testify/assert,最后整个测试文件都像脚本语言。

其实大部分场景,标准库完全够用:

  • if got != want
  • errors.Is
  • errors.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
}{
	// ...
}

这通常比 ParamsWant 这种抽象字段更容易读,尤其是业务测试。

泛型该用在“真正跨多个地方复用”的场景,不要为了看起来高级而牺牲可读性。

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 测的是实际性能问题吗?

表驱动测试只是工具,不是姿势。能把重复结构压平、让差异更清楚时,它非常好用;一旦表比被测代码还难读,就该停手了。