Ch. 22 · Software Testing

Snapshot Tests Worth Reviewing

Use node:test snapshots on a receipt, see a diff where a real bug hides among cosmetic changes, and keep snapshots small and readable.

~6 min readintermediateupdated Oct 19, 2026

A snapshot test saves the output of some code the first time it runs and compares later runs against the saved copy. Any difference fails the test. It’s an attractive deal: one line of test code covers a whole rendered component, an email template or a JSON response. The catch is in what the test actually asserts. It doesn’t say “this output is correct”. It says “this output is the same as last time”. Whether that’s valuable depends almost entirely on what happens when the snapshot changes, which is why interviewers ask how you review them.

A receipt snapshot with Node’s test runner

Node 22 has snapshot support built into node:test through t.assert.snapshot. Here’s a function that formats a plain-text receipt, and a snapshot test for it:

export function receiptText(order) {
  const lines = order.items.map(
    (i) => `${String(i.qty).padStart(2)} x ${i.name.padEnd(14)} ${(i.qty * i.unitCents / 100).toFixed(2).padStart(8)}`,
  );
  const total = order.items.reduce((s, i) => s + i.qty * i.unitCents, 0);
  return [
    `Order ${order.id}`,
    ...lines,
    `${'Total'.padEnd(19)} ${(total / 100).toFixed(2).padStart(8)}`,
  ].join('\n');
}
JavaScript
import { test } from 'node:test';
import { receiptText } from './receipt.js';

const order = {
  id: 'A-1042',
  items: [
    { name: 'Canvas tote', qty: 2, unitCents: 1200 },
    { name: 'Enamel mug', qty: 1, unitCents: 850 },
  ],
};

test('receipt layout', (t) => {
  t.assert.snapshot(receiptText(order));
});
JavaScript

The first run fails, by design:

Error [ERR_INVALID_STATE]: Invalid state: Cannot read snapshot file '.../receipt.test.js.snapshot.' Missing snapshots can be generated by rerunning the command with the --test-update-snapshots flag.
Text

Running with --test-update-snapshots writes receipt.test.js.snapshot next to the test file. That file is the expected value, and it gets committed with the code.

Make the snapshot something a person can read

Open that first snapshot file and you find:

exports[`receipt layout 1`] = `
"Order A-1042\\n 2 x Canvas tote       24.00\\n 1 x Enamel mug         8.50\\nTotal                  32.50"
`;
JavaScript

Node serialises values with JSON.stringify by default, so a multi-line string becomes one escaped line. Nobody can check column alignment in that, and nobody will. Since the whole value of a snapshot depends on someone reading it, fix this first. The serializers option replaces the default chain; for a string, the identity function is enough:

test('receipt layout', (t) => {
  t.assert.snapshot(receiptText(order), { serializers: [(text) => text] });
});
JavaScript

After regenerating, the stored snapshot is the receipt itself:

exports[`receipt layout 1`] = `
Order A-1042
 2 x Canvas tote       24.00
 1 x Enamel mug         8.50
Total                  32.50
`;
JavaScript

Now a reviewer can check it: two totes at $12.00 are $24.00, plus $8.50 is $32.50. That check is the real assertion. The snapshot only freezes it.

The same principle applies to UI snapshots. A 600-line serialised component tree with generated class names isn’t reviewable. Snapshot a small, meaningful piece, like the rendered text of one card, or the props passed to a child, rather than a whole page.

The diff where a bug hides

Some weeks later, a design change arrives: use a proper multiplication sign, drop the quantity padding, tighten the total line. The developer edits the template, and also, while there, simplifies the line amount. The test fails:

✖ receipt layout (4.0865ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  + actual - expected

    '\n' +
      'Order A-1042\n' +
  +   '2 × Canvas tote       12.00\n' +
  +   '1 × Enamel mug         8.50\n' +
  +   'Total                 32.50\n'
  -   ' 2 x Canvas tote       24.00\n' +
  -   ' 1 x Enamel mug         8.50\n' +
  -   'Total                  32.50\n'
Text

Every line changed, and that was expected: it’s a redesign. So the developer runs --test-update-snapshots, the test goes green, and the pull request shows a snapshot file where every line is different. A reviewer who skims sees “receipt redesign, snapshot updated” and approves.

Look again at the first new line: 2 × Canvas tote 12.00. The line amount is now the unit price, not quantity times unit price. The “simplification” dropped i.qty *. The total still says 32.50 because it’s computed separately, so the receipt doesn’t even add up. The snapshot test did catch the bug, in the sense that it failed. But it reported the bug as one of seven changed lines, mixed in with intended changes, and the update flag accepted all of them at once.

This is the central weakness of snapshot tests. A failing snapshot means “something changed”, and the cost of deciding whether each change is intended falls on a human reading a diff. When a snapshot fails for intended reasons most of the time, people stop reading.

Targeted assertions for the facts that matter

The fix isn’t to drop snapshots, but to stop relying on them for the facts that must never be wrong. Here, the amounts:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { receiptText } from './receipt.js';

test('line amounts add up to the printed total', () => {
  const text = receiptText({
    id: 'A-1042',
    items: [
      { name: 'Canvas tote', qty: 2, unitCents: 1200 },
      { name: 'Enamel mug', qty: 1, unitCents: 850 },
    ],
  });
  const amounts = text.split('\n').map((line) => Number(line.trim().split(/\s+/).at(-1)));
  const lineAmounts = amounts.slice(1, -1);
  const total = amounts.at(-1);
  assert.deepEqual(lineAmounts, [24, 8.5]);
  assert.equal(lineAmounts.reduce((a, b) => a + b, 0), total);
});
JavaScript

Against the redesigned code:

✖ line amounts add up to the printed total (1.894667ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
  + actual - expected

    [
  +   12,
  -   24,
      8.5
    ]
Text

This failure can’t be mistaken for a layout change, and nobody will “update” it. Restore the multiplication and both tests pass. The division of labour is clear: the targeted test protects the arithmetic, and the snapshot protects the layout, where “it looks the same as the approved version” is the right question.

(Comparing parsed floats like 8.5 works here because the values are short decimals; for anything that sums many amounts, compare integer cents.)

Values that change on every run

Snapshots also fail in the opposite way: constantly, for no reason. This test snapshots a whole order object:

function createOrder(items) {
  return { id: 'A-1043', createdAt: new Date().toISOString(), items };
}

test('new order', (t) => {
  t.assert.snapshot(createOrder([{ sku: 'MUG', qty: 1 }]));
});
JavaScript

Generated once and run again a second later, it failed with a single changed line:

  +   '  "createdAt": "2026-10-20T15:17:43.854Z",\n' +
Text

Timestamps, random IDs, UUIDs and anything that depends on locale or machine produce snapshots that fail on every run or every machine. Teams then get used to regenerating snapshots, which brings back the rubber-stamping problem. The fixes are to inject the clock and ID generator so the values are fixed in tests, or to replace those fields in a serializer before comparing (for example, map createdAt to "<timestamp>"), and assert separately that the real value is a valid ISO date.

A review checklist for snapshot changes

When a pull request touches a .snapshot file (or __snapshots__ directory in Jest and Vitest):

  • Does the PR description say why the output changed? An unexplained snapshot change is a question, not a formality.
  • Is every changed line explained by that reason? In the receipt diff, the × and the padding were explained by the redesign. 24.00 becoming 12.00 was not.
  • Did unrelated snapshots change? A CSS refactor that updates snapshots for the checkout total is a red flag.
  • Is the snapshot still small enough to read? If not, split it or replace part of it with targeted assertions.
  • Were snapshots regenerated wholesale? Running the update flag only on the test file you meant to change, rather than the whole suite, keeps accidental changes elsewhere out of the commit.

Exercise: the 4,000-line snapshot file

A React codebase has 300 component snapshots totalling 4,000 lines. CI fails on snapshots in roughly a third of pull requests, developers routinely run the update command, and last month a pricing bug shipped despite the snapshot of the price component changing in the PR. A new team lead proposes deleting all snapshot tests. What do you recommend?

Answer

The incident shows the snapshots are not functioning as assertions: they fail often for intended changes, so updating is a reflex and nobody reads the diffs. Deleting them all would also remove the cheap protection they give layouts that rarely change, though, so I’d triage rather than delete.

First, find the snapshots that fail most often in the last few months of CI history; those are usually large trees of markup that change with any styling tweak. Replace them with targeted tests of behaviour: rendered text, accessible roles, what happens on click. For the price component specifically, add explicit assertions on the displayed amounts for a few representative inputs, so a pricing bug fails a test whose name says “price”, not a snapshot. Second, shrink what’s left: snapshot small, stable outputs (a formatted address, an error message) with readable serializers, and strip unstable values like dates and generated IDs. Third, change the process: CI should never update snapshots, PRs that change snapshot files need a sentence explaining why, and reviewers use the checklist above. Success means snapshot failures become rare and, when they do happen, they’re read.

Keep exploring

More in Software Testing

read ✓Software Testing · hard

Testing Time with an Injected Clock

Pass a clock into time-dependent code, test expiry boundaries to the millisecond with node:test, and see why sleep-based tests flake.

~7 min readread →
read ✓Software Testing · mid

Code Coverage and Its Limits

See a 100% covered function ship a boundary bug, read line and branch numbers from Node's coverage report, and set thresholds that help.

~6 min readread →
read ✓Software Testing · mid

Unit and Integration Test Balance

Watch fast unit tests pass while a real SQLite test catches a swapped column and an untranslated unique-constraint error, then split the work.

~7 min readread →
esc