JavaRush /Courses /Go SELF /Golden files — store expected output in files

Golden files — store expected output in files

Go SELF
Level 53, Lesson 2
Available

1. Why you need golden files

When a test verifies CLI output, it’s very easy to fall into a trap: at first you have one line "todo-cli dev\n", and everything is fine. Then a list command appears that prints a task table, then you add filters, sorting, columns, line wraps… and suddenly your test contains 40 lines of a literal in wantOut. That’s no longer a test; it’s a short novel with a sad ending.

Golden files (a.k.a. “output snapshot files”) solve exactly this pain: instead of keeping a large expected text directly in test code, we put it into a file next to the tests. The test generates got, reads want from a file, and compares them. If the output changed intentionally, we update the snapshot with go test -update. If it changed accidentally, the test catches the regression.

There’s an important philosophy here: golden is not a “lazy test.” It’s a way to keep tests readable when the artifact under test is inherently textual and large (a table, help text, a multi-line report). In Go this is especially natural, because tests often work with text (stdout), and it’s convenient to store test data as separate files.

2. Where to store golden: testdata/

Before writing code, let’s agree on the “project geography.” In Go there’s a very stable tradition: any files needed only by tests (and that should not be part of the public API or the build) go into the testdata/ directory.

Why this is so convenient:

  • testdata/ immediately signals: “these are test-only data, don’t touch without a reason.”
  • Tools and developers expect to find test fixtures there.
  • The name testdata is almost a de-facto standard: a year from now you’ll thank yourself when you return to the code.

You can see a similar approach in real repositories in the Go ecosystem: in test data they clearly separate “input” and “want” (expected result) and store them in separate files so tests don’t turn into unreadable constructions.

Mini diagram: how a golden test works

Before code, it helps to imagine the process as a pipeline. We don’t start a separate CLI process — we call our Run(...) as a function (we already did that earlier), and now we add a “golden file.”

flowchart TD
    A["Test: prepare args + out/errOut buffers"] --> B["Run cli.Run(...)"]
    B --> C["Get got = out.String()"]
    C --> D["Read want from testdata/*.golden"]
    D --> E{"got == want?"}
    E -->|yes| F["PASS"]
    E -->|no| G{"is -update enabled?"}
    G -->|yes| H["Overwrite golden file with new got"]
    G -->|no| I["FAIL: print clear diagnostics"]

The idea is simple: by default we compare, and we overwrite the snapshot only on explicit request.

3. Helpers for golden files: readGolden and writeGolden

Now comes a very practical step: we’ll extract reading/writing golden files into small helper functions. Then tests will read like scenarios rather than bookkeeping with os.ReadFile.

Reading a golden file

The subheading sounds boring, but this is exactly the moment when you save yourself hours of life. A good helper does two things: first, it hides the “noise” of file operations; second, it provides a proper error via t.Fatalf so the test fails clearly and quickly.

package cli

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

func readGolden(t *testing.T, name string) string {
	t.Helper()

	path := filepath.Join("testdata", name)
	b, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read golden %s: %v", path, err)
	}
	return string(b)
}

Notice t.Helper(): thanks to it, if the test fails, Go points to the line in the test, not inside readGolden. It’s a small detail, but it makes debugging much nicer (especially when you end up with multiple helpers).

Writing a golden file

Writing is needed only for update mode. And it’s important not to slide into “the test always overwrites files” — otherwise you’ll stop noticing regressions, because “well, the test is green.”

package cli

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

func writeGolden(t *testing.T, name string, data string) {
	t.Helper()

	path := filepath.Join("testdata", name)
	if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
		t.Fatalf("write golden %s: %v", path, err)
	}
}

Permissions 0o644 are a fine default for a text file. Yes, on Windows this is not “about permissions” in the usual Unix sense, but for the purposes of this course it’s enough that the code works cross-platform.

4. The -update flag and snapshot updates

Now the key part: how to make updates controlled and safe.

We want:

  • by default: compare got with the snapshot want;
  • when running go test -update: if output changed, overwrite the golden file.

Declare the flag in _test.go

Declare the flag in a test file (for example, golden_test.go) and at the package level — that way the test runner picks it up.

package cli

import "flag"

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

And here’s an important point: usually we do not call flag.Parse() manually in tests. The test runner go test calls it. If you start calling flag.Parse() yourself, you can get weird effects: conflicts with go test’s own flags, unexpected behavior with parallel packages, and in general “why did it break only in CI while it works locally?” — a classic.

The fact that flags in _test.go are part of the overall flag set of the test binary is conceptually similar to how Go treats special test constructs: they live in _test.go and are picked up by the testing infrastructure automatically. For example, example tests are also placed in _test.go and are run by go test as part of the package.

How to run updates

Let’s say you change the output format of list (for example, you added a column). Then you do:

go test ./... -run TestRun_List_Golden -update

or simply:

go test ./... -update

(depending on how confident you are that the changes are expected).

Mentally treat -update as “snapshot overwrite mode.” It should be enabled only when you intentionally change the output format.

5. CLI example: the list command

Now let’s assemble everything into a real scenario. We continue the learning app todo-cli. Imagine we have a list command that prints tasks. We will golden-test stdout of this command (and we’ll verify stderr and the exit code explicitly, as a proper CLI contract).

Mini task model and list rendering

To keep the example self-contained and short, we’ll implement rendering as a separate function. That’s good both for testing and for design: output formatting is a separate responsibility.

package cli

import (
	"fmt"
	"io"
)

type Task struct {
	ID    int
	Title string
	Done  bool
}

func renderTasks(out io.Writer, tasks []Task) {
	fmt.Fprintln(out, "ID  TITLE       DONE")
	for _, t := range tasks {
		fmt.Fprintf(out, "%-3d %-10s %v\n", t.ID, t.Title, t.Done)
	}
}

Yes, this is not an ideal table “like in production” (later we’ll get acquainted with text/tabwriter), but for understanding the golden approach it’s even more useful: minimal magic, maximum transparency.

The list command inside Run

Assume Run is already structured to parse a command and print the result. We’ll keep it as short as possible here:

package cli

import (
	"fmt"
	"io"
)

func Run(args []string, in io.Reader, out, errOut io.Writer) int {
	_ = in

	if len(args) == 0 {
		fmt.Fprintln(errOut, "missing command")
		return exitUsage
	}

	switch args[0] {
	case "list":
		tasks := []Task{
			{ID: 1, Title: "milk", Done: false},
			{ID: 2, Title: "code", Done: true},
		}
		renderTasks(out, tasks)
		return exitOK
	default:
		fmt.Fprintf(errOut, "unknown command: %s\n", args[0])
		return exitUsage
	}
}

In a real app, the task list would of course come from storage, but today what matters is different: how to test the output. Later we’ll separately discuss file isolation and t.TempDir(), and for now we’ll keep the example “in memory” to focus on golden.

6. Golden test for list

Now we write the test. It’s important to follow three things: we verify the exit code, separately verify stderr (in the good case it’s empty), and only then compare stdout via a golden file.

package cli

import (
	"bytes"
	"strings"
	"testing"
)

func TestRun_List_Golden(t *testing.T) {
	var out bytes.Buffer
	var errOut bytes.Buffer

	code := Run([]string{"list"}, strings.NewReader(""), &out, &errOut)

	if code != exitOK {
		t.Fatalf("code=%d, want %d", code, exitOK)
	}
	if gotErr := errOut.String(); gotErr != "" {
		t.Fatalf("stderr=%q, want empty", gotErr)
	}

	got := out.String()

	if *update {
		writeGolden(t, "list.golden", got)
	}

	want := readGolden(t, "list.golden")
	if got != want {
		t.Fatalf("stdout mismatch:\n--- got ---\n%s--- want ---\n%s", got, want)
	}
}

Notice: we make the mismatch message as “human-friendly” as possible by printing both texts with separators. Yes, it’s not a “smart diff,” but it already helps a lot to spot the difference by eye.

The testdata/list.golden file and the final newline

Create the file cli/testdata/list.golden (the path depends on your project layout) and put the expected output there:

ID  TITLE       DONE
1   milk       false
2   code       true

And an important micro nuance: the final newline. In our renderTasks, every line ends with \n. So the golden file should usually also end with a newline. Otherwise you’ll get a permanent “micro diff” that’s more annoying than a real bug.

7. Useful nuances and criteria for choosing golden

At this point you may want to golden-test everything in sight (that’s a normal feeling; many people do). But it’s better to think pragmatically: golden is a tool, not a religion.

Golden vs expectations directly in the test

Let’s compare approaches in a small table:

Situation What’s better Why
The expected string is short ("missing command\n") want := "..." in the test The test is instantly readable; a file only gets in the way
Large multi-line output (table, help, report) golden file Otherwise the test turns into a “wall of text”
Output changes often due to instability (time, random map order) first make the output deterministic Golden does not cure non-determinism; it masks it and annoys you

If the output depends on map iteration order, the current time, or absolute paths, golden will start “shaking.” Then you either stabilize the output, or you don’t use golden for that area.

Why go test supports this

In Go, the testing infrastructure likes verifying text output. The simplest example is example tests: they look like documentation, but they actually run, and go test compares the actual stdout with the // Output: block.

Golden files are the same idea, except instead of a // Output: comment we have a separate file with expected text.

Moral: comparing stdout is normal practice in Go. For large texts, it’s just more convenient to store expectations in files rather than in code.

8. Common mistakes when working with golden files

Mistake #1: golden is always updated “because it’s easier.”
If you overwrite the file on every test run, you turn the test into a decorative element. It stops catching regressions: any breakage just gets written into the snapshot, and the test becomes green. Correct mode: compare by default; overwrite only with -update.

Mistake #2: calling flag.Parse() manually in tests.
It looks logical if you’re used to regular programs, but for tests it often creates problems. The test binary’s flags are already managed by go test, and your flags declared in _test.go are picked up by the test runner automatically. In most projects, a manual flag.Parse() in tests does more harm than good.

Mistake #3: using golden where the output is non-deterministic.
If a command prints time via time.Now(), or prints map elements in random order, golden will “break” without any logic change. This leads to the habit of “just run with -update and forget,” and that’s the road to tests nobody trusts. First stabilize the output, and only then use golden.

Mistake #4: mixing stdout and stderr into one golden.
A CLI has two streams, and they carry different meaning: stdout is the result for the user/scripts, stderr is errors/usage/diagnostics. If you dump everything into one file, you lose an important part of the contract and make tests less precise. It’s better to golden-test stdout and check stderr separately with short strings or Contains.

Mistake #5: forgetting the final newline and getting “invisible” diffs.
A very common story: the golden file is missing the last \n, but the program prints it (or vice versa). Visually the texts are “almost the same,” but the test fails. The fix is discipline: agree that all CLI output ends with a newline, and keep that in the snapshots.

1
Task
Go SELF, level 53, lesson 2
Locked
Reading a golden file
Reading a golden file
1
Task
Go SELF, level 53, lesson 2
Locked
Updating the golden file
Updating the golden file
1
Task
Go SELF, level 53, lesson 2
Locked
Golden list
Golden list
1
Task
Go SELF, level 53, lesson 2
Locked
Case reports
Case reports
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION