Go 子测试组织入门:表驱动、t.Run 和测试命名怎么写

面向 Go 初学者的测试组织指南:用表驱动测试、t.Run、辅助函数、临时目录和环境变量把测试写得清楚可靠。

Go 的测试工具很朴素,没有复杂的断言 DSL,也没有必须学习一整套框架的压力。你只要创建一个 xxx_test.go 文件,写一个 func TestXxx(t *testing.T),再运行 go test,就已经能开始了。也正因为它朴素,很多初学者会在项目变大后遇到另一个问题:测试越写越散,失败信息不清楚,新增一个场景要复制一段函数,最后大家都不太愿意补测试。

Go 社区里最常见的组织方式是“表驱动测试”和“子测试”。表驱动测试把输入、期望值和场景名称放进一张表里;子测试用 t.Run 给每个场景单独命名。两者结合起来,测试会更短,也更容易定位失败。本文用几个普通业务函数做例子,讲清楚它们为什么有用,以及初学者容易踩到哪些小坑。

从重复测试开始

假设我们有一个函数,用来规范化用户输入的标签。规则很简单:去掉两端空白,转成小写,空字符串不允许:

package tag

import (
	"errors"
	"strings"
)

var ErrEmptyTag = errors.New("empty tag")

func Normalize(input string) (string, error) {
	value := strings.ToLower(strings.TrimSpace(input))
	if value == "" {
		return "", ErrEmptyTag
	}
	return value, nil
}

最直接的测试可能会这样写:

package tag

import "testing"

func TestNormalizeTrim(t *testing.T) {
	got, err := Normalize("  Go  ")
	if err != nil {
		t.Fatal(err)
	}
	if got != "go" {
		t.Fatalf("got %q, want %q", got, "go")
	}
}

func TestNormalizeLower(t *testing.T) {
	got, err := Normalize("HTTP")
	if err != nil {
		t.Fatal(err)
	}
	if got != "http" {
		t.Fatalf("got %q, want %q", got, "http")
	}
}

这当然能工作。但当规则变多时,文件里会出现大量结构相似的函数。每个函数都要处理 goterrwant,真正有价值的差异反而被样板代码盖住了。表驱动测试要解决的就是这个问题。

把场景放进一张表

表驱动测试通常先定义一个匿名结构体切片:

func TestNormalize(t *testing.T) {
	tests := []struct {
		name    string
		input   string
		want    string
		wantErr bool
	}{
		{name: "trim spaces", input: "  Go  ", want: "go"},
		{name: "lower case", input: "HTTP", want: "http"},
		{name: "empty after trim", input: "   ", wantErr: true},
	}

	for _, tt := range tests {
		got, err := Normalize(tt.input)
		if tt.wantErr {
			if err == nil {
				t.Fatalf("%s: expected error", tt.name)
			}
			continue
		}
		if err != nil {
			t.Fatalf("%s: unexpected error: %v", tt.name, err)
		}
		if got != tt.want {
			t.Fatalf("%s: got %q, want %q", tt.name, got, tt.want)
		}
	}
}

这已经比多个重复函数好一些。新增场景只要加一行表项。但它还有一个缺点:一旦某个场景失败,整个测试函数就停了。比如第一个场景失败后,后面的场景不会继续运行。另一个缺点是失败信息靠我们手动拼 tt.name,不够自然。

这时可以引入 t.Run

用 t.Run 给每个场景一个名字

子测试的写法如下:

func TestNormalize(t *testing.T) {
	tests := []struct {
		name    string
		input   string
		want    string
		wantErr bool
	}{
		{name: "trim spaces", input: "  Go  ", want: "go"},
		{name: "lower case", input: "HTTP", want: "http"},
		{name: "empty after trim", input: "   ", wantErr: true},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			got, err := Normalize(tt.input)
			if tt.wantErr {
				if err == nil {
					t.Fatal("expected error")
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if got != tt.want {
				t.Fatalf("got %q, want %q", got, tt.want)
			}
		})
	}
}

运行 go test -v 时,你会看到每个子测试的名字:

=== RUN   TestNormalize
=== RUN   TestNormalize/trim_spaces
=== RUN   TestNormalize/lower_case
=== RUN   TestNormalize/empty_after_trim
--- PASS: TestNormalize (0.00s)

这样失败定位更清楚。你还可以只运行某个子测试:

go test -run 'TestNormalize/lower_case'

tt := tt 这一行也值得注意。它会为每轮循环创建一个新的变量,避免闭包拿到循环变量导致混乱。在 Go 1.22 之后,循环变量语义已有调整,但很多项目仍然会保留这个写法,因为它兼容旧版本,也能让读者一眼看出这里有闭包。写测试时追求清楚,比追求少一行更重要。

测试名要描述场景,不要描述实现

表项里的 name 很关键。一个好的名字应该帮助你理解“什么情况下失败”,而不是重复函数内部实现。比如下面这些名字就不太好:

{name: "case1", input: "  Go  ", want: "go"}
{name: "strings.TrimSpace", input: "  Go  ", want: "go"}

case1 没信息量;strings.TrimSpace 暴露了实现细节。更好的名字是:

{name: "trim spaces and lower case", input: "  Go  ", want: "go"}
{name: "reject blank input", input: "   ", wantErr: true}

当 CI 上出现 TestNormalize/reject_blank_input 失败时,你不用打开代码就知道问题大概在哪。测试名也是文档的一部分。尤其对入门项目来说,好的测试名能帮新同事快速理解业务规则。

错误比较不要只看字符串

很多初学者会这样比较错误:

if err.Error() != "empty tag" {
	t.Fatalf("unexpected error: %v", err)
}

这很脆弱。错误文案调整后,业务语义没变,测试却失败。更推荐用 errors.Is

package tag

import (
	"errors"
	"testing"
)

func TestNormalizeError(t *testing.T) {
	_, err := Normalize(" ")
	if !errors.Is(err, ErrEmptyTag) {
		t.Fatalf("got error %v, want %v", err, ErrEmptyTag)
	}
}

放回表驱动测试中,可以把期望错误也放进表里:

func TestNormalizeWithErrorValue(t *testing.T) {
	tests := []struct {
		name    string
		input   string
		want    string
		wantErr error
	}{
		{name: "valid tag", input: " Go ", want: "go"},
		{name: "blank tag", input: " ", wantErr: ErrEmptyTag},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			got, err := Normalize(tt.input)
			if tt.wantErr != nil {
				if !errors.Is(err, tt.wantErr) {
					t.Fatalf("got error %v, want %v", err, tt.wantErr)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if got != tt.want {
				t.Fatalf("got %q, want %q", got, tt.want)
			}
		})
	}
}

这样测试的是错误类型或错误语义,而不是某一句文本。用户可见的错误文案应该另有测试或快照来覆盖,内部错误判断不要过度依赖字符串。

辅助函数要调用 t.Helper

当多个测试都要创建用户、准备临时文件、检查响应时,可以提取辅助函数。但辅助函数里如果直接 t.Fatal,失败行号默认会指向辅助函数内部,不一定指向调用处。t.Helper() 可以告诉测试框架:这是辅助函数,报告错误时请优先显示调用它的地方。

func mustNormalize(t *testing.T, input string) string {
	t.Helper()

	got, err := Normalize(input)
	if err != nil {
		t.Fatalf("Normalize(%q): %v", input, err)
	}
	return got
}

func TestMustNormalizeExample(t *testing.T) {
	got := mustNormalize(t, " Go ")
	if got != "go" {
		t.Fatalf("got %q, want %q", got, "go")
	}
}

辅助函数不要太“聪明”。如果一个 helper 同时创建数据库、写配置、启动服务、注册清理函数,测试读起来会像黑箱。更好的做法是让 helper 做一件明确的事,并通过名字表达出来,比如 newTestServerwriteConfigFilemustCreateUser。测试本身仍然应该能看出主要流程。

用 t.TempDir 管理临时文件

很多业务函数会读写文件。测试这类函数时,不要手动拼 /tmp/xxx,也不要把测试文件写到项目目录。Go 提供了 t.TempDir(),每个测试会拿到一个临时目录,测试结束后自动清理。

package config

import (
	"os"
	"path/filepath"
	"testing"
)

func TestLoadConfig(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "app.conf")

	err := os.WriteFile(path, []byte("port=8080\n"), 0644)
	if err != nil {
		t.Fatal(err)
	}

	cfg, err := Load(path)
	if err != nil {
		t.Fatal(err)
	}
	if cfg.Port != 8080 {
		t.Fatalf("got port %d, want 8080", cfg.Port)
	}
}

t.TempDir() 的好处不只是自动清理。它还让并发测试更安全,因为每个测试都有自己的目录,不会互相覆盖。初学者常见的 flaky test,很多都来自共享文件、共享环境变量、共享端口这类隐性状态。

环境变量用 t.Setenv

配置读取经常依赖环境变量。以前测试环境变量时,需要手动保存旧值并在 defer 里恢复。现在可以用 t.Setenv

func TestConfigFromEnv(t *testing.T) {
	t.Setenv("APP_PORT", "9090")

	cfg, err := LoadFromEnv()
	if err != nil {
		t.Fatal(err)
	}
	if cfg.Port != 9090 {
		t.Fatalf("got port %d, want 9090", cfg.Port)
	}
}

t.Setenv 会在测试结束时自动恢复环境变量,减少遗漏。要注意的是,环境变量是进程级状态,不适合和 t.Parallel() 随便混用。如果多个并行测试改同一个环境变量,结果很容易互相影响。测试代码里只要出现全局状态,就要先想清楚是否能并发。

t.Parallel 要谨慎使用

子测试支持并行运行:

func TestNormalizeParallel(t *testing.T) {
	tests := []struct {
		name  string
		input string
		want  string
	}{
		{name: "go", input: " Go ", want: "go"},
		{name: "http", input: " HTTP ", want: "http"},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			got, err := Normalize(tt.input)
			if err != nil {
				t.Fatal(err)
			}
			if got != tt.want {
				t.Fatalf("got %q, want %q", got, tt.want)
			}
		})
	}
}

纯函数测试很适合并行。它们不读写共享文件,不改环境变量,不连真实外部服务,也不依赖执行顺序。可一旦测试会操作数据库、缓存、全局配置、当前工作目录,就不要急着加 t.Parallel()。并行测试跑得快,但失败起来也更难查。入门阶段先写可靠测试,再考虑并行。

让失败信息包含输入和期望

一个好的失败信息应该能回答三个问题:测的是哪个场景,输入是什么,实际值和期望值分别是什么。比如:

if got != tt.want {
	t.Fatalf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
}

这比单纯的 t.Fatal("failed") 好太多。CI 日志里如果只有 failed,你还得拉代码、复现、加打印。测试失败信息写清楚,是对未来调试时间的尊重。

对于结构体比较,可以用 reflect.DeepEqual,或者在项目里引入更清楚的 diff 工具。标准库写法如下:

if !reflect.DeepEqual(got, tt.want) {
	t.Fatalf("got %#v, want %#v", got, tt.want)
}

%#v 会打印 Go 语法风格的值,比 %v 更适合看结构体字段。不要为了省一行错误信息,让将来的自己在日志里猜半天。

小结

Go 测试的核心不是工具复杂,而是组织清楚。表驱动测试让场景集中呈现,t.Run 让每个场景有名字,t.Helper 让辅助函数失败时定位更准确,t.TempDirt.Setenv 让临时状态更可靠。t.Parallel 可以加速测试,但要先确认测试之间没有共享状态。

对初学者来说,写测试最重要的不是一次学完所有技巧,而是从第一个表驱动测试开始,把输入、期望和场景名称写明白。当测试读起来像一份业务规则清单,它就不只是防回归工具,也会变成项目里最可信的文档之一。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页