frontend / testing / 08_snapshot_testing.md

Snapshot Testing — When and How (Sparingly)

5 min read source

Snapshot Testing — When and How (Sparingly)

TL;DR

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.

Interview Q&A

Q: How does a snapshot test work?

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

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:

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).

Q: Why are snapshots dangerous for UI components?

A: 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.

Q: When are snapshots useful?

A:

  • 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 snapshotstoMatchInlineSnapshot() 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.”

Q: toMatchInlineSnapshot() — the better default.

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

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.

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

A: Custom serializers or property matchers.

// 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.

Q: How do you review a snapshot PR?

A: 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.

Q: Snapshot serializers.

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

// 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.

Q: Snapshot vs visual regression — what’s the difference?

A:

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 10_a11y_and_visual_regression.md.

Q: When to delete snapshot tests?

A:

  • 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 / edge cases

  • 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.

What a senior is expected to say

  • “Snapshots are not assertions — they encode ‘nothing changed,’ which trains the team to -u reflexively. Use sparingly.”
  • “Inline snapshots (toMatchInlineSnapshot) over file-based for data — visible in PR diff, no orphan .snap files.”
  • “Snapshots are useful for data shape (JSON, normalized output, error messages), not for UI structure — behavior tests with role-based queries are more meaningful.”
  • “Visual regression is the right tool for visual changes; snapshots are for structural changes; behavior tests are for behavior changes. Pick by intent.”
  • “If a snapshot is > 50 lines or updated routinely without thought, delete it and write a behavior test.”

Cross-references

Further reading