Snapshot Testing in Go With the go snaps Package Explained
Snapshot testing in Go with go-snaps: store expected output in a reviewed file, read the diff when it changes, update on purpose, and avoid snapshot sprawl.

Some outputs are too big to write by hand and too important to leave unchecked: a rendered report, a generated SQL statement, an API response with thirty fields, the output of a code generator. Writing the expected value inline means a screenful of string literal that nobody reads. Snapshot testing is the alternative: the expected value lives in a file, created by the first run, and later runs fail when the output changes.
Tested with Go 1.25.6, go-snaps v0.5.24, macOS on Apple Silicon (arm64)
This article uses go-snaps v0.5.24 on Go 1.25.6, shows what the files and the failures look like, and says honestly when the technique helps and when it only moves the work around.
A function worth snapshotting
package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
type Invoice struct {
ID string `json:"id"`
Lines []Line `json:"lines"`
Tags map[string]int `json:"tags"`
}
type Line struct {
SKU string `json:"sku"`
Qty int `json:"qty"`
Cents int `json:"cents"`
}
func Render(inv Invoice) string {
var b strings.Builder
fmt.Fprintf(&b, "Invoice %s\n", inv.ID)
total := 0
for _, l := range inv.Lines {
fmt.Fprintf(&b, " %-6s x%d %6.2f\n", l.SKU, l.Qty, float64(l.Cents*l.Qty)/100)
total += l.Cents * l.Qty
}
fmt.Fprintf(&b, "Total %.2f\n", float64(total)/100)
return b.String()
}
func JSON(inv Invoice) string {
keys := make([]string, 0, len(inv.Tags))
for k := range inv.Tags {
keys = append(keys, k)
}
sort.Strings(keys)
b, _ := json.MarshalIndent(inv, "", " ")
return string(b)
}
func main() {}Render formats an invoice as aligned text, and JSON serialises it. Both produce output where the exact shape matters (the column alignment, the field order) and where an assertion written by hand would be long. They are good candidates.
package main
import (
"testing"
"github.com/gkampitakis/go-snaps/snaps"
)
var sample = Invoice{
ID: "inv-1",
Lines: []Line{{"pen", 3, 150}, {"book", 1, 1299}},
Tags: map[string]int{"b": 2, "a": 1},
}
func TestRenderSnapshot(t *testing.T) {
snaps.MatchSnapshot(t, Render(sample))
}
func TestJSONSnapshot(t *testing.T) {
snaps.MatchJSON(t, JSON(sample))
}The lifecycle
F='s/ \([0-9.]+s\)//; s/\t[0-9.]+s$//'
echo "# first run creates the snapshots"
go test -count=1 . 2>&1 | sed -E "$F"
ls __snapshots__
echo; echo "# the stored snapshot, as a file you review"
cat __snapshots__/report_test.snap | head -14
echo; echo "# second run compares"
go test -count=1 . 2>&1 | sed -E "$F"
echo; echo "# change the code: amounts get wider"
perl -pi -e "s/%6.2f/%8.2f/" report.go
go test -count=1 . 2>&1 | grep -vE '^\s*$' | sed -E "$F" | head -22
echo; echo "# accept the new output with UPDATE_SNAPS=true"
UPDATE_SNAPS=true go test -count=1 . 2>&1 | sed -E "$F" | grep -E 'ok|Snapshot|updated'# first run creates the snapshots
ok example.test/lab
report_test.snap
# the stored snapshot, as a file you review
[TestRenderSnapshot - 1]
Invoice inv-1
pen x3 4.50
book x1 12.99
Total 17.49
---
[TestJSONSnapshot - 1]
{
"id": "inv-1",
"lines": [
{
# second run compares
ok example.test/lab
# change the code: amounts get wider
--- FAIL: TestRenderSnapshot
report_test.go:16:
- Snapshot - 2
+ Received + 2
Invoice inv-1
- pen x3 4.50
- book x1 12.99
+ pen x3 4.50
+ book x1 12.99
Total 17.49
↵
at __snapshots__/report_test.snap:2
FAIL
FAIL example.test/lab
FAIL
# accept the new output with UPDATE_SNAPS=true
ok example.test/labFirst run. There is no snapshot, so go-snaps writes one: __snapshots__/report_test.snap, beside the test. The test passes. The file holds an entry per snapshot, labelled [TestRenderSnapshot - 1], then the exact text. This file is the specification, and it belongs in version control.
Second run. The package finds the entry and compares. Same output, same result, pass.
A change. The script widens the amount column from 6 to 8 characters. The test fails and prints a diff in the style of diff -u: lines removed from the snapshot with -, lines received with +. You can see precisely that pen x3 4.50 became pen x3 4.50. That diff is the product. A snapshot test earns its keep by turning an unexpected change in a large output into a short, readable diff.
Accepting. UPDATE_SNAPS=true go test rewrites the snapshots that differ. In the lab it ended in ok, and the new output is now the reference. In a pull request, the changed .snap file shows up in the diff, so the reviewer sees the old and new output side by side. That is the control: the update is a deliberate act and it is reviewed.
JSON snapshots
snaps.MatchJSON takes a string or bytes holding JSON and normalises it before it is stored or compared: keys are sorted and the indentation is fixed. A harmless reformat or a different key order therefore does not fail the test. For output built from a map, as the tags are here, determinism is still your job: Go's encoding/json sorts map keys when encoding, which is why the snapshot is stable, but code that builds text by ranging over a map needs the keys sorted first, as JSON shows with its sorted key slice.
There are also helpers for other cases, such as matching a Go value with snaps.MatchStandaloneSnapshot into a file of its own, and options to ignore volatile fields. Check the package documentation for the current set; the lab uses only the two functions above.
Keep snapshots small and stable
Snapshot tests fail in a particular way: not by being wrong but by being noisy. Three habits keep them useful.
- Snapshot one thing. A test that snapshots a whole page response breaks when anyone edits any part of the page. Snapshot the piece that the test is about, such as the invoice body, not the envelope around it.
- Remove volatile values. Timestamps, generated IDs, absolute paths and map orderings make the snapshot differ on every run. Inject a clock and an ID source, as in the determinism article, or scrub the fields before comparing.
- Keep it readable. A snapshot that the reviewer cannot read, such as a binary blob or a minified line of 4,000 characters, cannot be reviewed, and an unreviewed snapshot is the same as no assertion. Pretty print JSON, wrap long text.
The risk: updating without reading
The cost of this technique is human. A failing snapshot test is easy to "fix": run with UPDATE_SNAPS=true, and it is green. Do that without reading the diff and you have a test that always passes. Teams that adopt snapshots often find that after a few months nobody reads the changes, because they are frequent and large.
Mitigations are cultural and mechanical. Make updates a separate, labelled commit so reviewers know to look. Refuse to update in CI: go-snaps treats missing snapshots as failures when the CI environment variable is set, so a snapshot is not silently created on the build server. And prefer an explicit assertion whenever a short one would do. if got != "Total 17.49" states intent. A snapshot states "whatever this is now", and that can include a bug.
Where snapshots fit
Good fits: generated code and templates, rendered documents, SQL builders, formatted CLI output, serialisation formats, error message catalogues. Poor fits: logic with a few clear outputs (use a table), anything with inherent randomness, and anything small enough to inline. A mix works well, with golden files for large fixtures you maintain by hand, and snapshots when you want the tool to manage the files. The two ideas are close relatives: golden files are the standard-library way of doing the same thing, with a flag to update, and go-snaps adds the file management, the diff output and the update switch.
Limits
- A snapshot proves stability, not correctness. The first run records whatever the code produced, bug included. Read the first snapshot as carefully as you would write an assertion.
- Diffs can be large. One change to a shared helper can touch fifty snapshots. That is a signal, either that the change is bigger than intended or that the snapshots are too coarse.
- Files accumulate. Obsolete snapshots remain after a test is deleted; the package can report them, and
UPDATE_SNAPS=cleanremoves unused entries. - Merge conflicts happen in
.snapfiles, and the resolution is to re-run the update after merging, then review.
The package is documented in its repository, and the broader family of expected-output tests is covered in the testing guide. For the Go-native way, see the testing package reference.


