Skip to content

Write a snapshot test snippet

A snapshot test records a function's output once, stores it, and fails when the output changes — the test you write without writing the expectation.

A snapshot test records a function's output once, stores it, and fails when the output changes — the test you write without writing the expectation. The trap is the update reflex: every framework offers a flag that re-records snapshots, so a careless --update blesses broken output as the new truth; CI should never run with it. Determinism is the precondition: now(), random ids, and iteration order make snapshots flaky — freeze time and seed randomness or the snapshot is noise.

Runnable recipe · 12 languagesOpen the text-diff tool →
Testing & QAtestingsnapshot-testgolden-filesregression-testci

Every language

12 implementations, copy-ready. One at a time with syntax highlighting, or all inline.

JSJavaScript
import { expect, it } from 'vitest';

function renderReport(rows) {
  return ['Report', '======', ...rows.map(([name, qty]) => `${name}: ${qty}`)].join('\n');
}

it('renders the report', () => {
  // first run records the snapshot; every run after compares against it
  expect(renderReport([['bolts', 12], ['washers', 340]])).toMatchSnapshot();
});

it('renders inline', () => {
  // empty on purpose — vitest writes the recorded value into this call:
  expect(renderReport([['nuts', 7]])).toMatchInlineSnapshot();
});

The danger flag is vitest -u / --update: it re-records every failing snapshot in the run, blessing whatever the code now prints as the new truth — run it locally on a diff you have read, never in CI. toMatchInlineSnapshot stores the recording inside the test file itself, so the recorded expectation rides through code review instead of hiding in a __snapshots__ directory.

TSTypeScript
import { expect, it } from 'vitest';

interface Row { name: string; qty: number }

function renderReport(rows: Row[]): string {
  return ['Report', '======', ...rows.map((r) => `${r.name}: ${r.qty}`)].join('\n');
}

it('renders the report', () => {
  expect(renderReport([{ name: 'bolts', qty: 12 }])).toMatchSnapshot();
});

The snapshot records vitest's pretty-format serialization of the value — a Date field or a random id lands in the file verbatim and rots the snapshot on every run. expect.addSnapshotSerializer({ test: (v) => v instanceof Date, print: () => '<date>' }) tames the volatile field at the serialization boundary — scrub it there, once, instead of in every fixture.

GoGo
import (
	"bytes"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

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

func renderReport(rows [][2]any) string {
	lines := []string{"Report", "======"}
	for _, r := range rows {
		lines = append(lines, fmt.Sprintf("%v: %v", r[0], r[1]))
	}
	return strings.Join(lines, "\n")
}

func TestRenderReport(t *testing.T) {
	got := renderReport([][2]any{{"bolts", 12}, {"washers", 340}})
	golden := filepath.Join("testdata", "report.golden")
	if *update {
		if err := os.WriteFile(golden, []byte(got), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	want, err := os.ReadFile(golden)
	if err != nil {
		t.Fatalf("read %s: %v (first run? re-record with: go test ./... -update)", golden, err)
	}
	if !bytes.Equal([]byte(got), want) {
		t.Errorf("report changed; if intended, re-record with: go test ./... -update")
	}
}

No stdlib snapshot API — the golden-file pattern is the whole idiom, and testdata/ is the conventional home. The trap: -update is the sanctioned workflow (`go test ./... -update`), so CI must pin its test command WITHOUT the flag — a stray -update in a workflow re-records every golden and the suite goes green on top of broken output.

RsRust
use insta::assert_snapshot;

fn render_report(rows: &[(&str, u32)]) -> String {
    let mut out = String::from("Report\n======");
    for (name, qty) in rows {
        out.push_str(&format!("\n{name}: {qty}"));
    }
    out
}

#[test]
fn renders_report() {
    assert_snapshot!(render_report(&[("bolts", 12), ("washers", 340)]));
}

A failing run writes renders_report.snap.new beside the committed .snap and keeps failing until `cargo insta accept` — blessing is a separate explicit step, and the pending .snap.new file surfacing in git status is what forces the new truth through review. Settings.bind with filters redacts volatile substrings (timestamps, ids) before anything is recorded.

PHPPHP
use PHPUnit\Framework\TestCase;
use Spatie\Snapshots\MatchesSnapshots;

final class RenderReportTest extends TestCase
{
    use MatchesSnapshots;

    public function testRendersReport(): void
    {
        $this->assertMatchesSnapshot(
            $this->renderReport([['bolts', 12], ['washers', 340]])
        );
    }

    private function renderReport(array $rows): string
    {
        $lines = ['Report', '======'];
        foreach ($rows as [$name, $qty]) {
            $lines[] = "{$name}: {$qty}";
        }
        return implode("\n", $lines);
    }
}

Re-recording is `vendor/bin/phpunit -d update-snapshots` — spatie's own flag, not a PHPUnit core one; per-vendor flag names differ across the PHP snapshot packages, so pin the exact command in CONTRIBUTING and keep it out of CI. Snapshots land in a __snapshots__/ directory next to the test — that directory's diff in the PR is the approval.

PyPython
import os
from pathlib import Path

SNAPSHOTS = Path(__file__).parent / 'snapshots'

def render_report(rows):
    return '\n'.join(['Report', '======'] + [f'{n}: {q}' for n, q in rows])

def test_renders_report():
    got = render_report([('bolts', 12), ('washers', 340)])
    golden = SNAPSHOTS / 'report.golden'
    if not golden.exists() or os.environ.get('UPDATE_SNAPSHOTS'):
        golden.write_text(got)  # re-record — then review the golden's git diff
    assert got == golden.read_text()

pytest-regressions packages this exact idiom (its file_regression.check() diffs written output against a committed file). Approving a changed golden is a code review of the .golden diff — nothing in the tooling can tell you whether the new output is a feature or a bug, which is why UPDATE_SNAPSHOTS never runs in CI.

C#C#
using System.Text;
using VerifyXunit;
using Xunit;

[UsesVerify]
public class RenderReportTests
{
    static string RenderReport(params (string Name, int Qty)[] rows)
    {
        var b = new StringBuilder("Report\n======");
        foreach (var (name, qty) in rows)
            b.Append($"\n{name}: {qty}");
        return b.ToString();
    }

    [Fact]
    public Task RendersReport() =>
        Verifier.Verify(RenderReport(("bolts", 12), ("washers", 340)));
}

First run fails and writes RenderReportTests.RendersReport.received.txt; accepting — via the diff tool Verify launches, or Verify.DiffPilot — moves it to .verified.txt, which you commit. Verify auto-detects CI and refuses to write or accept there (tests just fail), and the auto-accept escape hatches (VerifierSettings.OnFirstVerify, AutoVerify) are forbidden by convention: blessing belongs to a human reading the diff.

JvJava
import org.approvaltests.Approvals;
import org.junit.jupiter.api.Test;

import java.util.List;

class RenderReportTest {
    static String renderReport(List<Object[]> rows) {
        StringBuilder b = new StringBuilder("Report\n======");
        for (Object[] r : rows) b.append("\n").append(r[0]).append(": ").append(r[1]);
        return b.toString();
    }

    @Test
    void rendersReport() {
        Approvals.verify(renderReport(List.of(
                new Object[]{"bolts", 12},
                new Object[]{"washers", 340})));
    }
}

Approvals.verify writes RenderReportTest.rendersReport.received.txt and fails until a matching .approved.txt exists — the received/approved pair is the review artifact: the diff tool shows exactly what changed, and approving is copying received over approved (the IDE plugin binds it to a key). Format with \n, never %n — the platform line separator makes the snapshot itself differ between a Windows laptop and Linux CI.

SwSwift
import Foundation
import XCTest

func renderReport(_ rows: [(name: String, qty: Int)]) -> String {
    (["Report", "======"] + rows.map { "\($0.name): \($0.qty)" })
        .joined(separator: "\n")
}

final class RenderReportTests: XCTestCase {
    func testRendersReport() throws {
        let got = renderReport([("bolts", 12), ("washers", 340)])
        let goldenURL = Bundle(for: RenderReportTests.self)
            .url(forResource: "report", withExtension: "golden")
        let want = try String(contentsOf: XCTUnwrap(goldenURL), encoding: .utf8)
        XCTAssertEqual(got, want) // re-record = edit the bundled golden by hand
    }
}

Neither XCTest nor Swift Testing (the 2024 successor framework) has a built-in snapshot matcher — the stdlib answer is a golden file added to the test target's bundle and compared with XCTAssertEqual, and re-recording means editing that resource by hand, which keeps blessing deliberate. pointfree's SnapshotTesting library (assertSnapshot(of:as:)) is the ecosystem heavyweight once you outgrow plain strings.

KtKotlin
import org.approvaltests.Approvals
import org.junit.jupiter.api.Test

class RenderReportTest {
    private fun renderReport(vararg rows: Pair<String, Int>) = buildString {
        append("Report\n======")
        rows.forEach { (name, qty) -> append("\n$name: $qty") }
    }

    @Test
    fun rendersReport() {
        Approvals.verify(renderReport("bolts" to 12, "washers" to 340))
    }
}

Same ApprovalTests as Java — the received/approved pair and the diff-driven review carry over unchanged, because it is a JVM library rather than a language one. Kotest, the usual Kotlin-first alternative, has no first-class snapshot matcher, so ApprovalTests is the honest answer from Kotlin too.

RbRuby
require 'fileutils'
require 'minitest/autorun'

def render_report(rows)
  (['Report', '======'] + rows.map { |name, qty| "#{name}: #{qty}" }).join("\n")
end

class TestRenderReport < Minitest::Test
  GOLDEN = File.join(__dir__, 'snapshots', 'report.golden')

  def test_renders_report
    got = render_report([['bolts', 12], ['washers', 340]])
    if ENV['UPDATE_SNAPSHOTS'] || !File.exist?(GOLDEN)
      FileUtils.mkdir_p(File.dirname(GOLDEN))
      File.write(GOLDEN, got) # re-record — the golden's diff is the review
    end
    assert_equal(File.read(GOLDEN), got)
  end
end

The ecosystem gap vs JS: rspec-snapshot is thin and minitest has nothing built in, so hand-rolled golden files — write once, compare ever after — are the honest Ruby idiom. Re-record with UPDATE_SNAPSHOTS=1 and let the git diff on snapshots/report.golden be the review; the env var never reaches CI.

ZigZig
const std = @import("std");

const Row = struct { name: []const u8, qty: u32 };

fn renderReport(allocator: std.mem.Allocator, rows: []const Row) ![]u8 {
    var buf: [4096]u8 = undefined;
    var fbs = std.io.fixedBufferStream(&buf);
    const w = fbs.writer();
    try w.writeAll("Report\n======");
    for (rows) |r| try w.print("\n{s}: {d}", .{ r.name, r.qty });
    return allocator.dupe(u8, fbs.getWritten());
}

test "renderReport matches golden" {
    const alloc = std.testing.allocator;
    const got = try renderReport(alloc, &.{
        .{ .name = "bolts", .qty = 12 },
        .{ .name = "washers", .qty = 340 },
    });
    defer alloc.free(got);

    // regenerate: ZIG_UPDATE_SNAPSHOTS=1 zig build test
    if (std.posix.getenv("ZIG_UPDATE_SNAPSHOTS")) |_| {
        try std.fs.cwd().makePath("testdata");
        try std.fs.cwd().writeFile(.{
            .sub_path = "testdata/report.golden",
            .data = got,
        });
        return;
    }
    const golden = try std.fs.cwd().readFileAlloc(
        alloc, "testdata/report.golden", 1 << 20);
    defer alloc.free(golden);
    try std.testing.expectEqualStrings(golden, got);
}

No snapshot library exists, so the golden file read through std.fs.cwd — synchronous, allocation-explicit, comptime-friendly — is the whole pattern. Regeneration rides an env var: ZIG_UPDATE_SNAPSHOTS=1 zig build test. The trap is the same as every language here: the env var is one keystroke away from a CI config, and CI must never set it.

Keep going

Try the interactive text-diff tool →