snap

package module
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 10, 2026 License: MIT Imports: 13 Imported by: 0

README

go-snap

Snapshot testing for text in Go: CLI output, rendered documents, language-server responses, interactive terminal sessions.

You declare what a suite's snapshot files may contain and write one function that drives the system under test. go-snap discovers the files, runs a subtest per case, compares, diffs, and rewrites expectations when you accept them.

var suite = snap.Suite{
	Run:     runCase,
	Inputs:  []snap.Input{{Name: "ARGS", List: true, Quoted: true}},
	Outputs: []snap.Output{{Name: "STDOUT"}, {Name: "STDERR"}, {Name: "EXIT", Int: true}},
}

func TestSnapshots(t *testing.T) { snap.Run(t, "snapshots", &suite) }

func runCase(t *testing.T, c *snap.Case) {
	stdout, stderr, exit := myCLI(c.List("ARGS"))
	c.Out("STDOUT", stdout)
	c.Out("STDERR", stderr)
	c.OutInt("EXIT", exit)
}

That is the whole integration. Cases live in snapshots/*.snap:

### TITLE ###
greets a name
### ARGS ###
World
### STDOUT ###
Hello, World!

### TITLE ###
unknown flag names the flag
### ARGS ###
--yell
### STDERR ###
greet: unknown flag --yell
### EXIT ###
2

One file holds many cases, so related behavior is reviewed together. Adding a case means adding text, not writing Go.

Accepting new output

When output changes, the failure tells you where and how to accept it:

STDOUT mismatch:
1 │ - Hello, World! │ 1 │ + Howdy, World!

  at snapshots/cli/basic.snap:5
  to accept: -update=snapshots/cli/basic.snap

Updates are targeted. -update=<substr> rewrites only files whose path contains one of the given substrings, and a mismatch in any other file still fails the run. Accepting one change therefore cannot quietly absorb an unrelated regression somewhere else - the failure mode that makes a blanket "update everything" flag dangerous.

go test ./... -update=errors/validation     # accept one area
go test ./... -update=types,errors          # accept several
go test ./... -update-all                   # deliberate sweep
SNAP_UPDATE=errors go test ./...            # same, via the environment

SNAP_UPDATE exists for go test ./... across a repo where not every package imports go-snap: an unknown -update flag would be rejected by the packages that never registered it, while the environment variable reaches all of them.

Two guards apply. A value starting with - is refused, because -update -run TestFoo makes Go's flag parser swallow -run as the update target and rewrite whatever the run touches. And updates are refused when CI is set, since nobody reviews the result there; set SNAP_UPDATE_CI=1 for a job that exists to propose updates.

An update also reformats. go-snap rewrites a file whenever its canonical rendering differs from what is on disk, not only when output changed, so -update-all doubles as a formatter and a format migration can be applied to existing snapshots.

Cases with steps

Some systems cannot be tested one call at a time. An Action is a section that may repeat, carry arguments in its header, and produce its own output:

var suite = snap.Suite{
	Run:     runSession,
	Actions: []snap.Action{{Name: "SET", Args: 2}, {Name: "GET", Args: 1}},
	Outputs: []snap.Output{{Name: "RESULT", PerStep: true}, {Name: "FINAL"}},
}

func runSession(t *testing.T, c *snap.Case) {
	s := newStore()
	for _, step := range c.Steps() {
		switch step.Name {
		case "SET":
			step.Out("RESULT", s.set(step.Args[0], step.Args[1]))
		case "GET":
			step.Out("RESULT", s.get(step.Args[0]))
		}
	}
	c.Out("FINAL", s.dump())
}
### TITLE ###
setting an existing key reports what it replaced
### SET k first ###
### RESULT ###
created
### SET k second ###
### RESULT ###
replaced "first"
### FINAL ###
k = second

A PerStep channel is written directly under the action that produced it, so a reader never counts to find out which result belongs to which step.

The single-call suite is this same model with no actions declared. Nothing is paid for the generality up front, and a suite that grows into sequences does not have to change shape - the alternative is discovering mid-project that the format cannot express what you need, which is how snapshot harnesses end up forked.

Argument syntax belongs to the suite. go-snap splits header tokens and checks how many there are; what 2:5 means is the runner's business.

Files

A section header is ### NAME ### on its own line, and everything until the next header is its body. ### TITLE ### starts a case; titles are unique within a file and become the subtest name.

### DESCRIPTION ### records why a case exists. ### SKIP ### skips it, with the body as the reason.

A header naming a section the suite never declared is an error, not content. A mistyped delimiter should fail loudly rather than become expected output that asserts nothing.

Absent and empty differ, and mean different things on each side. For inputs, c.Has("STDIN") distinguishes an empty section from a missing one. For outputs, an absent section asserts the channel's zero value, so a case with no STDERR section asserts that stderr is empty. A channel the runner never sets asserts nothing at all - which is how a suite makes an assertion conditional. The converse, a section in the file that the runner never sets, is an error: it is an expectation nothing verifies.

Because absence carries that meaning on both sides, an update also removes a section it can reproduce: write ### EXIT ### with a body of 0 and the next accepted update deletes it. The assertion is unchanged - an absent EXIT still requires zero - but the line you typed will not survive.

Bodies are stored verbatim, which is what makes these files worth reviewing. A body line is terminated by its newline, and the last of those newlines is the separator before the next header - so each blank line before that header is one more trailing newline in the value, and every value is expressible:

### INPUT ###
foo
                  # -> "foo\n"
### STDOUT ###

Inputs are faithful because an input is data the runner feeds to the system under test, not a claim about it; a section that dropped trailing bytes would hand the runner something the file does not say. Text outputs still forgive trailing newlines when compared, because whether a stream ends in one is usually incidental, and pinning it in every case would be noise rather than assurance. Declare a channel Raw when it is not incidental.

The writer separates cases with a blank line, so one blank line before a TITLE is structure rather than content. A body that really ends in a blank line before the next case writes two.

Three kinds of line cannot survive a verbatim round trip - one that would re-read as a header, one an editor would strip trailing whitespace from, one carrying a carriage return - and the writer escapes exactly those as a backslash followed by a Go-quoted string:

### STDOUT ###
ordinary line
\"### TITLE ###"
\"trailing spaces   "

When a section is about bytes rather than lines, declare it Raw or mark one instance ### STDOUT [raw] ###. Its body is a single Go-quoted string, compared exactly, and its diffs quote each line so invisible differences are visible.

A List input is one item per non-blank line, whitespace trimmed, and carries no escape convention: a line means the characters in it. Add Quoted and an item may be written as a Go-quoted string, which is the only way to express an empty item, a space-padded one, or one holding a newline.

Markers changes the header syntax when ### collides with the content you snapshot:

Markers: snap.Markers{Prefix: "=== ", Suffix: " ==="}

Choose them before the suite has files. Changing them later leaves every existing snapshot unparseable, since its headers are written in the old syntax.

Section names are strings in two places - the Suite and the runner - and nothing links them. For a suite big enough that the drift would go unnoticed, keep the declaration in a variable and read the name off it:

var KeysSection = snap.Input{Name: "KEYS", List: true}

keys := c.List(KeysSection.Name)

Determinism

go-snap does not scrub timestamps, paths, or IDs, and offers no matcher syntax for "any number here".

Inject the nondeterminism instead. A suite that passes a fixed clock and a deterministic ID sequence gets stable snapshots by construction and keeps the real values visible in the file, where a reviewer can see that the timestamp is wrong. Scrubbing hides exactly the values a snapshot exists to pin. When it is genuinely unavoidable - a temp directory in an error message - do it in the runner before the value reaches a channel:

c.Out("STDERR", strings.ReplaceAll(stderr, sandbox.Home, "$HOME"))

Fixtures follow the same principle: ### FIXTURE ### is an ordinary input section, and a map[string]func() in the runner is the entire feature.

Interactive prompts

The prompt package snapshots terminal prompts. Scripted keystrokes go in, rendered frames come out:

### KEYS ###
"su"
enter
### CHOICE ###
Sushi
### FRAMES ###
--- frame 0 (initial) ---
Pick a food
> Hamburger
  Pizza
  Sushi
--- frame 1 (s) ---
Pick a food
/s
> Sushi

KEYS is hand-authored; FRAMES regenerates under -update like any other channel. Each frame is labeled with the keystroke that produced it, which is what lets a reviewer tell a rendering bug from an input-handling bug.

Install prompt.NoKeys{} as the driver for cases that script nothing. It fails immediately instead of falling through to a terminal that is not there, where the suite would hang and look like broken infrastructure.

Prompts are driven by radish, which the package's types name directly rather than wrapping. The core package does not import it.

Parallelism

Set Parallel: true and each case runs with t.Parallel(). File rewrites happen in a cleanup, which Go runs only after every subtest has finished, so updating and parallelism compose. Suites whose runner touches package-level state should leave it off.

What go-snap does not do

  • Determinism seams. Clocks, IDs, and environment belong to your harness.
  • Running your code. The runner produces strings however it likes; go-snap never spawns a process.
  • Obsolete-snapshot detection. Every file found is run, so a snapshot cannot be orphaned by a deleted test. Deleting a case means deleting text.
  • Review tooling. Updates are applied in place and reviewed as a git diff. .snap.new is reserved so a pending-snapshot workflow can be added later without changing the format.

Reference

Suite
Run func(t *testing.T, c *snap.Case)
Inputs unique hand-authored sections: {Name, List, Quoted, Raw}
Actions repeatable ordered sections: {Name, Args, HasBody}; Args: -1 is variadic
Outputs machine-generated channels: {Name, PerStep, Int, Raw}
Markers header syntax; zero value is ### NAME ###
Parallel run cases with t.Parallel()
Diff {Width, Color, Func}; honors NO_COLOR and SNAP_NO_COLOR

Case: Title, Description, Has, Text, List, Steps, Out, OutInt, Expected, ExpectedInt. Step: Name, Args, Body, Out, OutInt, Expected, ExpectedInt.

ParseFile and WriteFile expose the format layer for tooling, and Expected reads what a file records for a channel. A suite needs none of these - a runner reports what happened and lets the engine compare - but a formatter, a linter, or a review tool reading a pending file does.

example/ is a working suite of each shape, and is run by CI.

Documentation

Overview

Package snap is a snapshot-testing library for text: CLI output, rendered documents, language-server responses, interactive terminal sessions.

A suite is declared as data - which sections its files may contain, and which output channels its runner reports - plus one function that drives the system under test. The library owns everything else: discovering files, parsing them, running a subtest per case, comparing, diffing, and rewriting expectations when you accept them.

var suite = snap.Suite{
	Run:     runCase,
	Inputs:  []snap.Input{{Name: "ARGS", List: true, Quoted: true}},
	Outputs: []snap.Output{{Name: "STDOUT"}, {Name: "EXIT", Int: true}},
}

func TestSnapshots(t *testing.T) { snap.Run(t, "snapshots", &suite) }

func runCase(t *testing.T, c *snap.Case) {
	stdout, exit := runCLI(c.List("ARGS"))
	c.Out("STDOUT", stdout)
	c.OutInt("EXIT", exit)
}

Files

One file holds many cases, so related behavior is reviewed together:

### TITLE ###
rejects an unknown flag
### ARGS ###
--nope
### STDERR ###
unknown flag: --nope
### EXIT ###
2

Sections a suite never declared are an error, not content: a mistyped delimiter should fail loudly rather than quietly become expected output that asserts nothing.

Cases with steps

An Action is a section that may repeat, carry header arguments, and produce its own output - enough to snapshot a session rather than a single call:

### HOVER 1:6 ###
### RESULT ###
`foo`: int
### RENAME 0:0 bar ###
### RESULT ###
{"changes": 2}

The single-shot case above is the same model with no steps, so a suite can grow into sequences without changing shape.

Accepting new output

Snapshot updates are targeted:

go test ./... -update=errors/validation

Only matching files are rewritten; a mismatch anywhere else still fails, so accepting one change cannot quietly absorb an unrelated regression. Use -update-all for a deliberate sweep, or SNAP_UPDATE=<substr> when the flag cannot reach the test binary. Every failure prints the exact command that would accept it.

Determinism

The library does not scrub timestamps, paths or IDs. Suites that inject a fixed clock and deterministic IDs get stable snapshots by construction, and keep the real values visible in the file; scrubbing is a last resort, applied in the runner before the value reaches a channel.

Index

Constants

View Source
const (
	Ext        = ".snap"
	PendingExt = ".snap.new"
)

Ext is the snapshot file extension. PendingExt is reserved: files carrying it are never discovered as suite files, leaving room for a review workflow that writes proposed output beside the accepted file rather than over it.

Variables

This section is empty.

Functions

func Run

func Run(t *testing.T, dir string, s *Suite)

Run executes every .snap file under dir as subtests of t.

Each case becomes a subtest named "<file>/<title>", so `-run` can select one file or one case. Under -update, a file is rewritten once all of its cases have finished.

func WriteFile

func WriteFile(s *Suite, path string, cases []*Case) error

WriteFile renders cases in canonical form and writes them to path.

The path is a parameter rather than a property of the cases so a caller can write somewhere other than where it read - the seam a pending-snapshot workflow would need.

Types

type Action

type Action struct {
	Name string

	// Args is the exact number of header argument tokens, or -1 for variadic.
	Args int
	// HasBody allows a body under the header. An action without it rejects
	// one; an action with it may still be written empty, since "no content"
	// is usually a case worth testing rather than a mistake.
	HasBody bool
}

Action is a repeatable hand-authored section. Each occurrence in a case is one step, in file order, and may carry inline header arguments:

### HOVER 2:5 ###

Argument syntax beyond tokenization belongs to the suite: the engine splits and counts tokens, the runner interprets them.

type Case

type Case struct {
	// contains filtered or unexported fields
}

Case is one snapshot case: its hand-authored inputs and steps, and the output channels the runner sets.

func ParseFile

func ParseFile(s *Suite, path string) ([]*Case, error)

ParseFile reads a .snap file against a suite's schema. It is exported for tooling - editors, migration scripts, orphan reports - that wants to read snapshot files without running them.

func (*Case) Description

func (c *Case) Description() string

Description is the case's DESCRIPTION, or "" if it has none. It is prose for humans; the engine never interprets it.

func (*Case) Expected

func (c *Case) Expected(channel string) (string, bool)

Expected returns what the case's file records for an output channel, and whether the file carries that section at all.

Absent is not the same as unasserted: a missing section asserts the channel's zero value. The flag exists so a tool can tell the two apart, since only one of them has a line to rewrite. Suites do not need this - a runner reports what the system did and lets the engine compare - but a formatter, a linter, or a review tool reading a pending file does.

func (*Case) ExpectedInt

func (c *Case) ExpectedInt(channel string) (int, bool)

ExpectedInt is Expected for an Int channel.

func (*Case) Has

func (c *Case) Has(section string) bool

Has reports whether the case declares the given input section at all, distinguishing a section present but empty from one that is absent.

func (*Case) List

func (c *Case) List(section string) []string

List returns an input section's items, or nil if absent.

func (*Case) Out

func (c *Case) Out(channel, value string)

Out records a case-scoped output channel. Setting a channel asserts it; leaving it unset asserts nothing. Setting it to "" asserts that it is empty.

func (*Case) OutInt

func (c *Case) OutInt(channel string, value int)

OutInt records an integer output channel.

func (*Case) Steps

func (c *Case) Steps() []*Step

Steps returns the case's action steps in file order.

func (*Case) Text

func (c *Case) Text(section string) string

Text returns an input section's body, or "" if absent.

func (*Case) Title

func (c *Case) Title() string

Title is the case's TITLE, unique within its file.

type DiffConfig

type DiffConfig struct {
	// Width of the rendered diff. Default 120.
	Width int
	// Color forces color on or off. Nil auto-detects, honoring NO_COLOR and
	// SNAP_NO_COLOR.
	Color *bool
	// Func replaces the renderer entirely.
	Func func(want, got string) string
}

DiffConfig tunes how a mismatch is rendered. The goal a suite should hold it to: a failure is diagnosable from the test output alone, without opening the snapshot file to compare by hand.

type Input

type Input struct {
	Name string

	// List parses the body as one item per non-blank line, whitespace trimmed.
	List bool
	// Quoted lets a List item be written as a Go-quoted string, which is the
	// only way to express an empty or whitespace-significant item.
	Quoted bool
	// Raw stores the body as a single Go-quoted string, preserving bytes
	// exactly - trailing whitespace, CRs and all.
	Raw bool
}

Input is a unique hand-authored section carrying a body.

type Markers

type Markers struct {
	Prefix string // default "### "
	Suffix string // default " ###"
}

Markers is the section header syntax. A header line is Prefix + NAME + (" " + arg)... + Suffix, alone on its line.

type Output

type Output struct {
	Name string

	// PerStep correlates the channel with a step rather than the case, and
	// writes it directly under that step's header.
	PerStep bool
	// Int stores an integer; absent means zero.
	Int bool
	// Raw compares bytes exactly rather than forgiving trailing newlines, and
	// stores the value as a single Go-quoted string.
	Raw bool
}

Output is a machine-generated channel. The runner sets it; -update regenerates it from what the runner set.

type RunFunc

type RunFunc func(t *testing.T, c *Case)

RunFunc drives the system under test for one case. It reads the case's hand-authored inputs and steps, and reports results by setting output channels. It never sees expected values: comparison is the engine's job, so a runner cannot accidentally become the assertion.

type Step

type Step struct {
	// Name is the action's section name.
	Name string
	// Args are the header argument tokens, already split.
	Args []string
	// Body is the section body, empty for header-only actions.
	Body string
	// contains filtered or unexported fields
}

Step is one occurrence of an Action within a case.

func (*Step) Expected

func (s *Step) Expected(channel string) (string, bool)

Expected returns what the file records for a per-step channel on this step, and whether the section is present.

func (*Step) ExpectedInt

func (s *Step) ExpectedInt(channel string) (int, bool)

ExpectedInt is Expected for an Int channel.

func (*Step) Out

func (s *Step) Out(channel, value string)

Out records a per-step output channel for this step.

func (*Step) OutInt

func (s *Step) OutInt(channel string, value int)

OutInt records an integer per-step output channel for this step.

type Suite

type Suite struct {
	Run RunFunc

	Inputs  []Input
	Actions []Action
	Outputs []Output

	// Markers sets the section header syntax. The zero value is "### NAME ###",
	// chosen to avoid collision with most host-language syntax; override when
	// the content you snapshot would fight it.
	Markers Markers

	// Parallel runs each case with t.Parallel. Opt in only when the runner
	// holds no cross-case global state - the engine's own bookkeeping is
	// parallel-safe either way.
	Parallel bool

	Diff DiffConfig
}

Suite declares a snapshot suite: its section schema, its runner, and its options. A schema is a table, so declare it as a struct literal.

The library owns TITLE, DESCRIPTION and SKIP. Everything else is yours: Inputs are unique hand-authored sections, Actions are repeatable ordered ones forming a case's step sequence, and Outputs are machine-generated channels regenerated under -update.

Directories

Path Synopsis
Package example holds two toy systems and the snapshot suites that test them.
Package example holds two toy systems and the snapshot suites that test them.
Package prompt snapshots interactive terminal prompts.
Package prompt snapshots interactive terminal prompts.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL