Frontend / Testing / 08_snapshot_testing.md

Snapshot Testing — When and How (Sparingly)

Updated 5 interview angles 6 min read source
On this page13
  1. How does a snapshot test work?
  2. Why are snapshots dangerous for UI components?
  3. When are snapshots useful?
  4. toMatchInlineSnapshot() — the better default.
  5. How do you handle dynamic values in snapshots (dates, IDs)?
  6. How do you review a snapshot PR?
  7. Snapshot serializers.
  8. Snapshot vs visual regression — what’s the difference?
  9. When to delete snapshot tests?
  10. Gotchas
  11. Related
  12. Further reading
  13. Interview angle

Snapshot Testing — When and How (Sparingly)

A snapshot test captures the rendered output (HTML, JSON, string) and asserts it matches the stored snapshot file. Excellent for serialized data, dangerous for component output. The senior position: snapshots are not assertions — they’re “the test passes if nothing changed visually,” which doubles as “every cosmetic change requires updating snapshots,” which trains the team to --updateSnapshot reflexively. Use snapshots sparingly and inline when used; favor explicit expect(getByRole(...)) assertions for component behavior.

How does a snapshot test work?

First run: serializes the input, writes to __snapshots__/file.test.ts.snap. Subsequent runs: serializes again, compares to the saved file, fails on mismatch.

ts
import { render } from "@testing-library/react";
import { describe, it, expect } from "vitest";

it("renders button", () => {
  const { container } = render(<Button>Click</Button>);
  expect(container.firstChild).toMatchSnapshot();
});

Creates Button.test.ts.snap:

text
exports['renders button 1'] = `
<button class="btn">
  Click
</button>
`;

Next run: re-renders, serializes, diffs against saved. Mismatch → test fails with the diff. Update with vitest -u (run flag).

Why are snapshots dangerous for UI components?

Three failure modes:

  1. Drift through --updateSnapshot. Any UI change fails the test. Devs update snapshots reflexively without reviewing. A real regression (an <h1> becoming a <div> losing semantics) gets ignored.
  2. No assertion intent. The snapshot says “this is the output” — but what aspect of the output matters? Class name? Text? Structure? You can’t tell from the test.
  3. Whitespace and ordering noise. Tailwind class reordering, attribute reordering, formatting tweaks — all break snapshots without behavior change.

A behavior-oriented test (expect(getByRole("button", { name: /click/i })).toBeInTheDocument()) is smaller, more durable, more meaningful. Snapshot tests of large component trees are net-negative in most codebases.

When are snapshots useful?

  • Pure data transforms — JSON normalizer, GraphQL response shape, configuration computation. The snapshot is the contract.
  • Tiny presentational components with deliberate structure that should never change without review.
  • Error messages, generated strings, prose output — the format matters.
  • Inline configuration snapshots — toMatchInlineSnapshot() keeps the expected value in the test file (PR diff visible).
  • Compiler/build artifact tests — Vite plugin output, code transformation results.

The common thread: the snapshot is a deliberate contract about an output the team wants to lock down, not a passive record of “what the component happens to render.”

toMatchInlineSnapshot() — the better default.

Stores the snapshot in the test file as a string literal:

ts
expect(normalize(data)).toMatchInlineSnapshot(`
  {
    "active": true,
    "id": 1,
    "name": "Ada",
  }
`);

Benefits over file-based:

  • Visible in PR diff — the reviewer sees the expected value next to the assertion.
  • No .snap file to forget to commit or accidentally .gitignore.
  • Closer coupling between the assertion and what’s being asserted.

For data-shaped tests, always prefer inline. Files are noise.

How do you handle dynamic values in snapshots (dates, IDs)?

Custom serializers or property matchers.

ts
// Property matcher — assert shape, exact value is checked separately
expect(result).toMatchSnapshot({
  createdAt: expect.any(String),
  id: expect.any(Number),
});

For dates, mock Date.now() (vi.useFakeTimers().setSystemTime(...)); for IDs, mock the ID generator.

A snapshot full of "id": "abc123-def-..." and "createdAt": "2025-..." is fragile by construction. Either normalize or assert shape, not exact value.

How do you review a snapshot PR?

Read the diff carefully. If the snapshot diff:

  • Shows a structural change (<div> → <button>): intentional? Verify the test purpose is preserved.
  • Shows ordering changes (attributes, classes): probably accidental — what tool reorders?
  • Shows whitespace: usually formatter; safe.
  • Shows data change: is it real behavior change or a tracking timestamp leaking?

Approving --updateSnapshot without reading is the cardinal sin. If reviewers skip snapshot diffs by default (because there are 50 of them), the snapshots are too granular — replace with behavior tests.

Snapshot serializers.

Customize how Vitest/Jest serializes objects. Useful for stripping noise (test IDs, internal refs, dynamic IDs):

ts
// test/serializers.ts
expect.addSnapshotSerializer({
  test: (val) => typeof val === "object" && val !== null && "createdAt" in val,
  serialize: (val, config, indentation, depth, refs, printer) => {
    return printer({ ...val, createdAt: "[date]" }, config, indentation, depth, refs);
  },
});

Or use @emotion/jest’s enzyme-to-json style libraries that produce cleaner output than the default DOM serializer.

Snapshot vs visual regression — what’s the difference?

Snapshot Visual regression
What text serialization (HTML, JSON) pixel image (screenshot)
Catches structural changes visual changes (CSS, layout)
Reviews text diff in PR image diff (Chromatic, Percy UI)
Cost free service / infra
Best for data shape UI appearance

They’re not substitutes. Visual regression catches a CSS color change that snapshot wouldn’t. Snapshot catches a class name change that visual regression wouldn’t notice if it doesn’t affect rendering. See A11y Testing + Visual Regression in CI.

When to delete snapshot tests?

  • If you find yourself running --updateSnapshot more than once a month for the same file.
  • If the snapshot is > 50 lines (too coarse to review usefully).
  • If you can’t articulate what about the output matters.
  • If a behavior assertion would catch the same regression.

Delete; replace with behavior tests.

Gotchas

  • toMatchSnapshot updates silently on first run — your “initial” snapshot is the committed contract, but a fresh checkout creates it again on first run. Diff carefully on first commit.
  • CI vs local snapshots — if the snapshot includes platform-dependent output (file paths, OS-specific newlines), the test passes locally and fails in CI. Normalize before snapshotting.
  • Multiple snapshots per test — each toMatchSnapshot() call increments a counter; reordering them invalidates all subsequent snapshots in the test. Name them: toMatchSnapshot("name").
  • --ci mode in Jest disables snapshot creation — expect(x).toMatchSnapshot() fails instead of writing a new file. Prevents accidental snapshot generation in CI.
  • Large components snapshot the full subtree — hundreds of lines per snapshot. Either narrow with within() or replace with behavior tests.

Further reading

Interview angle 5

  • “What is wrong with snapshot tests?” - they are not assertions. A snapshot encodes “nothing changed”, not “this is correct”, so when it fails the cheapest response is -u - and once a team learns that reflex, the snapshot protects nothing at all.
  • “When is a snapshot the right tool?” - for data shape: a normalised output, a serialised error, an API response transform. Those diffs are small and readable, so a change actually gets reviewed rather than accepted.
  • “How would you decide between the three kinds of test?” - by what changed. Behaviour tests for behaviour, snapshots for structural or data shape, visual regression for anything visual. A snapshot cannot tell you a colour is wrong and a visual test cannot tell you a handler stopped firing.
  • “Inline or file-based?” - inline for data, since the expected value is visible in the PR diff and there are no orphaned .snap files left behind when a test is deleted.
  • “When should a snapshot be deleted?” - when it is longer than about fifty lines, or when it gets updated routinely without anyone reading the diff. Either way it has stopped being a test; replace it with a behaviour test that asserts the thing you actually cared about.