Go 黄金文件测试入门:适合输出很长的文本和 JSON

介绍 Go golden file 测试的适用场景、目录组织、更新方式、JSON 稳定化和常见误区,适合测试输出较长内容的新手。

有些函数的输出很长,比如生成 Markdown、渲染配置文件、格式化 JSON、生成 SQL。你当然可以在测试里写一个很长的字符串常量,但测试文件会变得难读。黄金文件测试,也叫 golden file test,就是把期望输出放在单独文件里,测试时读取文件并比较。

黄金文件不是万能测试方式。它最适合“输出长但稳定”的场景。本文用一个生成配置文本的例子,讲目录组织、更新方式、JSON 稳定化和常见误区。

一个普通字符串测试的问题

假设函数生成 Nginx 片段:

func RenderServer(domain string, port int) string {
	return fmt.Sprintf(`server {
    listen 80;
    server_name %s;

    location / {
        proxy_pass http://127.0.0.1:%d;
    }
}
`, domain, port)
}

普通测试:

func TestRenderServer(t *testing.T) {
	got := RenderServer("example.com", 8080)
	want := "server {\n    listen 80;\n    server_name example.com;\n\n ..."
	if got != want {
		t.Fatalf("got %q, want %q", got, want)
	}
}

输出一长,测试就很难维护。换行、缩进、空格都混在 Go 字符串里。黄金文件可以把期望输出放到 testdata/server.golden

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
    }
}

测试代码:

func TestRenderServerGolden(t *testing.T) {
	got := RenderServer("example.com", 8080)
	want, err := os.ReadFile("testdata/server.golden")
	if err != nil {
		t.Fatal(err)
	}
	if got != string(want) {
		t.Fatalf("output mismatch\n got:\n%s\nwant:\n%s", got, want)
	}
}

testdata 是 Go 测试常用目录名,go test 不会把它当普通包处理,很适合放测试素材。

增加 update 参数

当输出变化是有意的,手动复制新内容到 golden 文件很麻烦。可以加一个测试参数:

var update = flag.Bool("update", false, "update golden files")

func TestRenderServerGolden(t *testing.T) {
	path := "testdata/server.golden"
	got := RenderServer("example.com", 8080)

	if *update {
		if err := os.WriteFile(path, []byte(got), 0644); err != nil {
			t.Fatal(err)
		}
	}

	want, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	if got != string(want) {
		t.Fatalf("output mismatch\n got:\n%s\nwant:\n%s", got, want)
	}
}

更新方式:

go test ./... -update

注意,更新 golden 文件不是无脑操作。更新后要看 diff,确认变化符合预期。golden 测试最大的风险就是把错误输出也“批准”进文件。

输出差异要容易看

只用 got != want 可以判断失败,但长文本差异不容易看。入门阶段可以先打印 got 和 want。项目复杂后,可以引入 diff 工具,或者自己写一个简单行对比。标准库没有内置漂亮 diff,但你可以把输出写得更便于排查:

if got != string(want) {
	t.Fatalf("golden mismatch for %s\n--- got ---\n%s\n--- want ---\n%s", path, got, want)
}

如果输出包含很多行,CI 日志可能很长。可以只打印前几千字符,或者把 got 写到临时文件。关键是失败后能快速知道哪里变了。

JSON 输出要稳定

JSON golden 测试常见问题是字段顺序和缩进。结构体编码顺序通常稳定,map 编码顺序在现代 Go 中由 encoding/json 做了排序,但你仍然应该主动格式化,让文件可读:

func prettyJSON(t *testing.T, data []byte) string {
	t.Helper()

	var v any
	if err := json.Unmarshal(data, &v); err != nil {
		t.Fatal(err)
	}
	out, err := json.MarshalIndent(v, "", "  ")
	if err != nil {
		t.Fatal(err)
	}
	return string(out) + "\n"
}

测试时把实际输出规范化后再比较:

got := prettyJSON(t, RenderJSON())

这样 golden 文件不会因为一行 JSON 太长而难以审查。测试输出越容易读,golden 文件越有价值。

不要把时间和随机数直接写进去

如果输出里有当前时间、随机 ID、机器路径,golden 测试会不稳定。解决方式是把这些依赖注入:

type Renderer struct {
	Now func() time.Time
}

func (r Renderer) Render() string {
	now := r.Now()
	return now.Format(time.RFC3339)
}

测试里固定时间:

renderer := Renderer{
	Now: func() time.Time {
		return time.Date(2024, 10, 8, 10, 0, 0, 0, time.UTC)
	},
}

golden 测试要求输出稳定。如果每次运行都不同,测试就会变成噪音。不要靠正则把一堆不稳定字段抹掉,那会让测试失去意义。更好的设计是让生成逻辑可控。

多场景目录组织

场景多时,可以这样组织:

testdata/
  simple.golden
  with-auth.golden
  with-cache.golden

测试表:

func TestRenderGolden(t *testing.T) {
	tests := []struct {
		name string
		cfg  Config
	}{
		{name: "simple", cfg: Config{Domain: "example.com"}},
		{name: "with-auth", cfg: Config{Domain: "example.com", Auth: true}},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			path := filepath.Join("testdata", tt.name+".golden")
			got := Render(tt.cfg)
			assertGolden(t, path, got)
		})
	}
}

assertGolden 可以封装读取、更新和比较逻辑。封装时保持简单,不要让 helper 隐藏太多行为。

小结

黄金文件测试适合长文本、配置、Markdown、SQL、JSON 等稳定输出。它能让测试代码更清楚,也让期望内容更容易审查。常见做法是把文件放在 testdata,用 -update 控制更新,并在更新后认真看 diff。

不要用 golden 文件掩盖不稳定输出。时间、随机数、路径和外部环境都应该被固定或注入。黄金文件不是为了少写断言,而是为了让复杂输出的变化变得可见。

继续阅读

探索更多技术文章

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

全部文章 返回首页