Contents

Some test assertions are about structure, not specific values. JSON API responses, rendered HTML, error messages, log output — things where the exact expected value is long, tedious to maintain by hand, and changes often enough to be annoying. Snapshot testing solves this by storing the expected output in a file and comparing against it on subsequent runs.

If you’ve used Jest, you know the pattern. In Go, it’s less common — but gotest has a built-in implementation that’s thread-safe, works inside parallel tests, and integrates with CI safety guards.

The problem: testing large structured output

Consider a test that asserts on a JSON response:

func TestRenderResponse(t *testing.T) {
    resp := renderUserProfile(testUser)
    expected := `{
  "id": "usr-123",
  "name": "Alice Smith",
  "email": "alice@example.com",
  "roles": ["admin", "editor"],
  "preferences": {
    "theme": "dark",
    "notifications": true
  }
}`
    if resp != expected {
        t.Errorf("got %s, want %s", resp, expected)
    }
}

This works, but it has problems:

The test is correct, but it’s doing the wrong kind of work. The assertion logic is simple (does the output match?), but the expected value dominates the function. The important thing — what renderUserProfile is being called with and what the test is checking — is buried.

Golden files: the stdlib approach

The Go community’s conventional answer is golden files: store expected output in testdata/ and compare at runtime.

func TestRenderResponse(t *testing.T) {
    resp := renderUserProfile(testUser)

    golden := filepath.Join("testdata", t.Name()+".golden")

    if *update {
        os.WriteFile(golden, []byte(resp), 0644)
    }

    expected, err := os.ReadFile(golden)
    if err != nil {
        t.Fatal(err)
    }
    if resp != string(expected) {
        t.Errorf("output mismatch; run with -update to refresh")
    }
}

Better — the expected output is in a separate file. But you’re still managing the plumbing yourself:

gotest.MatchSnapshot

gotest.MatchSnapshot wraps this entire pattern into a single call:

func (s *UserAPITestSuite) TestRenderProfile(t *gotest.T) {
    resp := renderUserProfile(testUser)
    gotest.MatchSnapshot(t, resp)
}

That’s it. On first run, gotest creates testdata/__snapshots__/TestUserAPITestSuite.snap with the output. On subsequent runs, it compares the current output against the stored snapshot and shows a diff on mismatch.

No boilerplate. No manual file paths. No flag wiring. The test reads exactly like what it does: render a profile, check that it matches the snapshot.

Where snapshots live

Snapshot files are stored in testdata/__snapshots__/, next to the test file. One .snap file is created per top-level test suite. Each subtest gets its own named section within the file:

testdata/__snapshots__/TestUserAPITestSuite.snap
=== SNAP TestRenderProfile/the_user_has_roles/renders_the_profile ===
{
  "id": "usr-123",
  "name": "Alice Smith",
  "email": "alice@example.com",
  "roles": ["admin", "editor"],
  "preferences": {
    "theme": "dark",
    "notifications": true
  }
}
=== SNAP TestRenderProfile/the_user_is_new/renders_the_profile ===
{
  "id": "usr-456",
  "name": "Bob Jones",
  "email": "bob@example.com",
  "roles": [],
  "preferences": {
    "theme": "light",
    "notifications": false
  }
}

Sections are sorted alphabetically within the file, so the order is deterministic regardless of test execution order. The files are plain text — they diff cleanly in pull requests, and reviewers see exactly what changed in the output.

What gets snapshotted

MatchSnapshot serializes the value based on what interface it implements, checked in this order:

  1. string — used directly as the snapshot content.
  2. []byte — converted to string.
  3. json.RawMessage — pretty-printed as JSON (an explicit case: since Go 1.27, RawMessage has a String() method and would otherwise fall through to the Stringer branch unindented).
  4. encoding.TextMarshaler — calls MarshalText().
  5. fmt.Stringer — calls String().
  6. json.Marshaler — marshals and pretty-prints the JSON.
  7. error — calls Error().
  8. io.Reader — reads the content (restores position for seekable readers).

This means types that already know how to serialize themselves work without any extra code. For example, a json.RawMessage value gets pretty-printed automatically:

gotest.MatchSnapshot(t, json.RawMessage(`{"user":"alice","role":"admin"}`))

The snapshot file contains the pretty-printed JSON, not the compact input:

{
  "user": "alice",
  "role": "admin"
}

Custom snapshot names

When a single test produces multiple outputs to snapshot, pass an optional name as the third argument:

gotest.MatchSnapshot(t, renderProfile(admin), "admin-profile")
gotest.MatchSnapshot(t, renderProfile(guest), "guest-profile")

The name becomes part of the section key in the snapshot file. Without it, both calls would resolve to the same section key — the second call gets compared against the first call’s stored snapshot and fails. With it, each snapshot gets its own section and can be compared independently.

Updating snapshots

When the output changes intentionally — you added a field, changed a format, updated a template — the snapshot needs to be updated. Pass the --update-snapshots flag:

gotest --update-snapshots ./...

Or set the environment variable:

GOTEST_UPDATE_SNAPSHOTS=1 gotest ./...

This overwrites all snapshot files with the current output. Review the diff with git diff before committing — the snapshot files are version-controlled, so you can see exactly what changed and verify it’s what you intended.

Reviewing snapshot changes in PRs

Because snapshot files live in testdata/__snapshots__/, they show up in pull request diffs like any other source file. Alphabetical section ordering means that adding a new test doesn’t shift existing sections around — unrelated snapshots stay stable, and the diff shows only the sections that actually changed.

Reviewers see the actual output, not an assertion about it. If a change to renderUserProfile adds an "avatar" field, the PR diff shows that field appearing in the snapshot. The reviewer doesn’t need to run the test to understand the impact.

CI safety: read-only snapshots

In CI mode (--ci flag, or auto-detected from the CI environment variable), snapshots are read-only:

This prevents a specific failure mode: a developer adds a new MatchSnapshot call, runs tests locally (which creates the baseline), but forgets to commit the snapshot file. Without CI protection, the test would silently pass in CI by creating a fresh snapshot, and the next run would compare against that. With CI protection, the missing file is caught immediately.

Thread safety

MatchSnapshot is safe to call from parallel tests. Each snapshot file has its own mutex. Concurrent writes to the same file are serialized, and section ordering is deterministic regardless of execution order.

func (s *RenderTestSuite) SuiteConfig() gotest.SuiteConfig {
    return gotest.SuiteConfig{Parallel: true}
}

func (s *RenderTestSuite) TestAdminProfile(t *gotest.T) {
    gotest.MatchSnapshot(t, renderProfile(admin))
}

func (s *RenderTestSuite) TestGuestProfile(t *gotest.T) {
    gotest.MatchSnapshot(t, renderProfile(guest))
}

With Parallel: true, both test methods run concurrently and write to the same .snap file. The writes are serialized by the file’s mutex, sections are sorted on write, and the resulting file is identical whether the methods ran sequentially or in parallel.

When to use snapshot testing

Good fits

Poor fits

Prior art

Snapshot testing isn’t a new idea. Jest popularized it in JavaScript, and the pattern exists in most testing ecosystems. In Go, cupaloy and go-snaps are established snapshot libraries that offer this workflow as standalone packages; gotest’s version differs mainly in being integrated with its assertion set, safe to call from parallel tests, and read-only in CI mode.

gotest.MatchSnapshot handles the plumbing: file naming, section management, diff output, update workflow, CI guards, and thread safety. The test code stays focused on what it’s actually testing.

Snapshots cover the “is this output right?” half of the job. For the other recurring sources of test friction, Testing Async Code in Go shows how to wait for background work without time.Sleep, and Go Tests as Living Documentation shows how the same suites double as a browsable spec of your system’s behavior.

Try snapshot testing in your Go project.

go install github.com/mvrahden/go-test/cmd/gotest@latest