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.
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).
Why are snapshots dangerous for UI components?
Three failure modes:
- 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. - 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.
- 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:
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
.snapfile 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.
// 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):
// 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
--updateSnapshotmore 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
toMatchSnapshotupdates 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"). --cimode 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.
Related
Further reading
- Jest docs — Snapshot Testing: https://jestjs.io/docs/snapshot-testing
- Vitest — Snapshots: https://vitest.dev/guide/snapshot.html
- Kent C. Dodds — “Effective Snapshot Testing”: https://kentcdodds.com/blog/effective-snapshot-testing
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
.snapfiles 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.