有些函数的输出很长,比如生成 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 文件掩盖不稳定输出。时间、随机数、路径和外部环境都应该被固定或注入。黄金文件不是为了少写断言,而是为了让复杂输出的变化变得可见。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。