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")
}
}
这当然能工作。但当规则变多时,文件里会出现大量结构相似的函数。每个函数都要处理 got、err、want,真正有价值的差异反而被样板代码盖住了。表驱动测试要解决的就是这个问题。
把场景放进一张表
表驱动测试通常先定义一个匿名结构体切片:
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 做一件明确的事,并通过名字表达出来,比如 newTestServer、writeConfigFile、mustCreateUser。测试本身仍然应该能看出主要流程。
用 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.TempDir 和 t.Setenv 让临时状态更可靠。t.Parallel 可以加速测试,但要先确认测试之间没有共享状态。
对初学者来说,写测试最重要的不是一次学完所有技巧,而是从第一个表驱动测试开始,把输入、期望和场景名称写明白。当测试读起来像一份业务规则清单,它就不只是防回归工具,也会变成项目里最可信的文档之一。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。