Skip to content
gohacks.dev
Testing

Test Fixtures and Golden Files in Go and How to Update Them

Use golden files in Go tests for large outputs: a testdata directory, an -update flag, a first-difference message and review habits that keep them honest.

8 min read
Summarize in AI
Flat illustration of a test report with one failing test.

Some outputs are too big or too awkward to check with a handful of if statements: a rendered invoice, a generated SQL statement, a formatted JSON document, an HTML page, the output of a command. You could assert on a few lines and ignore the rest, which lets regressions through. Or you could store the whole expected output in a file and compare against it. That file is a golden file, and the technique is a standard part of Go's own test suites: the compiler, gofmt and go vet all use testdata directories of expected output.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds a small golden-file helper, runs the full life cycle (create, pass, fail on a change, update) and covers the rules that keep golden files useful rather than a place bugs hide. It follows table-driven tests and subtests, and the helper uses t.Helper. It ran on Go 1.25.6.

The function and the helper

Invoice renders a small plain-text invoice. Its output is a few lines of aligned columns, which is tedious to assert line by line and easy to compare as a whole:

report.goGo
package main

import (
	"fmt"
	"strings"
)

type Line struct {
	SKU   string
	Qty   int
	Cents int64
}

// Invoice renders a plain-text invoice. Output like this is awkward to assert on line by line.
func Invoice(customer string, lines []Line) string {
	var b strings.Builder
	fmt.Fprintf(&b, "INVOICE for %s\n", customer)
	b.WriteString(strings.Repeat("-", 34) + "\n")
	var total int64
	for _, l := range lines {
		sub := int64(l.Qty) * l.Cents
		total += sub
		fmt.Fprintf(&b, "%-14s %3d x %7.2f = %8.2f\n", l.SKU, l.Qty, float64(l.Cents)/100, float64(sub)/100)
	}
	b.WriteString(strings.Repeat("-", 34) + "\n")
	fmt.Fprintf(&b, "%-26s %8.2f\n", "TOTAL", float64(total)/100)
	return b.String()
}

func main() {}

The test file has the golden helper and a test that uses it:

report_test.goGo
package main

import (
	"flag"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
)

var update = flag.Bool("update", false, "rewrite the golden files from the current output")

// assertGolden compares got with testdata/<name>.golden. With -update it writes the file instead.
func assertGolden(t *testing.T, name, got string) {
	t.Helper()
	path := filepath.Join("testdata", name+".golden")
	if *update {
		if err := os.MkdirAll("testdata", 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
			t.Fatal(err)
		}
		return
	}
	want, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("missing golden file %s; run go test -update to create it: %v", path, err)
	}
	if string(want) != got {
		t.Errorf("output differs from %s\n%s", path, firstDifference(string(want), got))
	}
}

// firstDifference shows the first line that differs, which is usually enough to see what changed.
func firstDifference(want, got string) string {
	w, g := strings.Split(want, "\n"), strings.Split(got, "\n")
	for i := 0; i < max(len(w), len(g)); i++ {
		var a, b string
		if i < len(w) {
			a = w[i]
		}
		if i < len(g) {
			b = g[i]
		}
		if a != b {
			return "line " + strconv.Itoa(i+1) + "\n  want: " + a + "\n  got:  " + b
		}
	}
	return "(no line differs; trailing whitespace?)"
}

func TestInvoice(t *testing.T) {
	cases := map[string][]Line{
		"two_lines": {{"keyboard", 1, 7900}, {"cable", 3, 450}},
		"empty":     nil,
	}
	for name, lines := range cases {
		t.Run(name, func(t *testing.T) {
			assertGolden(t, name, Invoice("Acme Ltd", lines))
		})
	}
}

How the helper works:

  • A package-level -update flag is registered with flag.Bool. go test -update passes it through to the test binary.
  • assertGolden computes the path testdata/<name>.golden. The testdata directory name is special: the Go tool ignores it when building packages, so it is the conventional place for fixtures.
  • With -update, it creates the directory and writes the file. This is the only way the files change.
  • Without it, it reads the file and compares. A missing file is a failure with an explanation, not a silent pass: the message says to run go test -update.
  • On a mismatch it reports the first differing line with the expected and actual versions, which is usually enough to see what changed without a diff tool. For large outputs, use a real diff, such as cmp.Diff (comparing values with cmp.Diff), or print both and let git diff show the change after an update.
  • It calls t.Helper() so failures are attributed to the caller's line.

The test is a table keyed by name; each name is a subtest and a file. Using the subtest name as the file name keeps the mapping obvious.

The life cycle

Shell
strip() { sed -E 's/[[:space:]]+[0-9.]+s$//; s/ \([0-9.]+s\)//'; }
echo '$ go test            (no golden files exist yet)'
go test -count=1 . 2>&1 | strip | head -8
echo '$ go test -update'
go test -count=1 -update . 2>&1 | strip
echo '$ ls testdata'
ls testdata
echo '$ cat testdata/two_lines.golden'
cat testdata/two_lines.golden
echo '$ go test'
go test -count=1 . 2>&1 | strip
echo '--- change the format: print amounts with a currency sign'
cp report.go report.go.bak
sed -i.tmp 's/= %8.2f\\n/= $%8.2f\\n/' report.go
go test -count=1 . 2>&1 | strip | grep -v '^FAIL$'
echo '--- the change is intended, so review it and update'
go test -count=1 -update . 2>&1 | strip
cat testdata/two_lines.golden | sed -n 3,4p
mv report.go.bak report.go; rm -f report.go.tmp
Text
$ go test            (no golden files exist yet)
--- FAIL: TestInvoice
    --- FAIL: TestInvoice/empty
        report_test.go:61: missing golden file testdata/empty.golden; run go test -update to create it: open testdata/empty.golden: no such file or directory
    --- FAIL: TestInvoice/two_lines
        report_test.go:61: missing golden file testdata/two_lines.golden; run go test -update to create it: open testdata/two_lines.golden: no such file or directory
FAIL
FAIL	example.test/lab
FAIL
$ go test -update
PASS
ok  	example.test/lab
$ ls testdata
empty.golden
two_lines.golden
$ cat testdata/two_lines.golden
INVOICE for Acme Ltd
----------------------------------
keyboard         1 x   79.00 =    79.00
cable            3 x    4.50 =    13.50
----------------------------------
TOTAL                         92.50
$ go test
ok  	example.test/lab
--- change the format: print amounts with a currency sign
--- FAIL: TestInvoice
    --- FAIL: TestInvoice/two_lines
        report_test.go:61: output differs from testdata/two_lines.golden
            line 3
              want: keyboard         1 x   79.00 =    79.00
              got:  keyboard         1 x   79.00 = $   79.00
FAIL	example.test/lab
--- the change is intended, so review it and update
PASS
ok  	example.test/lab
keyboard         1 x   79.00 = $   79.00
cable            3 x    4.50 = $   13.50

Creation. With no testdata, the first run fails for both cases and says how to fix it. The second step, go test -update, writes empty.golden and two_lines.golden and passes. The listing and the content of one file are printed; golden files are plain text, readable in a review.

Verification. A normal go test passes: the output equals the files.

A regression. The script changes the formatting code to print a currency sign. The test fails for two_lines and reports the first differing line: want keyboard 1 x 79.00 = 79.00, got the same with $ inserted. empty still passes because an invoice with no lines does not print amounts per line. The failure is specific to the behaviour that changed.

An intended change. The change is deliberate, so the developer runs go test -update, which rewrites the files, and commits them with the code. The pull request then contains the code change and a diff of the golden files, and the reviewer sees exactly how the output differs. That diff is the review artefact. Golden files make output changes visible in review instead of buried in code.

Rules that keep golden files honest

Never update by default. The flag must be off unless asked for, and CI must never pass it. A test that rewrites its expectations whenever it fails is a test that cannot fail.

Review the diff of every update. The danger of golden files is "update and move on": the developer sees red, runs -update, and commits without reading, baking a bug into the expectation. Treat the diff of testdata like any other code in review. Some teams add a CI check that fails if go test -update changes anything (git diff --exit-code).

Keep outputs deterministic. Anything that varies between runs breaks golden tests: timestamps, random ids, map iteration order, absolute paths, hostnames, versions. Inject the clock and the id generator (injected clock), sort map keys before rendering, and normalise paths. If a field cannot be made deterministic, replace it before comparing (regexp.ReplaceAllString on the output).

Mind line endings and trailing newlines. A golden file written on Windows with CRLF will not match output on Linux. Mark them in .gitattributes (*.golden text eol=lf) so Git does not alter them.

Name files after cases, one case per file, so a failing test points at one file and -run selects one case.

Keep golden files small enough to review. If an output is a megabyte, a golden file is unreviewable and any change touches it all. Compare a summary, or split the output into sections.

Use the right comparison. For JSON, compare decoded values, or format both canonically first; for HTML or XML, compare normalised output. A byte-for-byte comparison of unordered JSON fails on whitespace and key order that do not matter.

Do not use golden files for logic with a few outputs. If the result is a number or a short string, assert on it directly. A golden file earns its place for large, structured text.

Beyond text

The same pattern works for binary data (images produced by a renderer, serialised protobuf), with a tolerance or a decoded comparison, for the output of command-line programs (testing a CLI), and for generated code, where the golden file is the expected generated source. Libraries exist (goldie, go-snaps, go-cmp's helpers), but the helper above is thirty lines and does what most projects need without a dependency.

The Go standard library uses the same idiom in many of its packages, with testdata/*.golden files and a -update flag; reading, for example, the tests in go/printer shows it at scale. The conventions for testdata are described in the go command documentation, and the Go blog post on testing with fixtures discusses tables and subtests that pair well with this technique.

Also inBasics
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.