C.W.K.
Stream
Lesson 04 of 04 · published

Snapshot Etiquette (Mostly: Don't)

~16 min · vitest-setup, snapshots, anti-pattern

Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"A snapshot you never read is not a test. It's a diff committed to main."

What a Snapshot Test Actually Does

A snapshot test serializes some value (an object, a rendered DOM tree, a string), stores the serialized form, and on subsequent runs compares the live value against the stored one. If they match, pass. If they don't, fail — and Vitest shows you the diff.

That's it. There's no assertion in the conventional sense — you're not declaring what the output should be, just that whatever it was last time, it should be that again. The seductive part is how little code you write. The dangerous part is the same.

Two Shapes — Inline and File

Inline snapshots live in the test file itself, as a multi-line string argument to toMatchInlineSnapshot. They survive code review because the diff is visible right there in the PR. Good for short, stable output.

File snapshots live in a sibling __snapshots__/ directory, separate from the test file. They scale to larger captured output but tend to drift out of review attention — people approve PRs without reading them. Use sparingly.

The snapshot review test: when this snapshot changes, will someone actually read the diff? If the answer is no, the snapshot is a rubber stamp, not a test. Delete it and write a real assertion.

The Update Flow (and Its Trap)

When a snapshot fails because the output legitimately changed (you refactored, you added a field), you update it: vitest -u or vitest --update. This regenerates the snapshot file from the current output, and you commit the diff.

The trap is muscle memory. After three or four cycles of "snapshot failed → press U → commit," the act of looking at what changed disappears. Now any unintended regression that touches the snapshot also gets U'd into oblivion. The test stopped catching anything months ago; nobody noticed because failures always end in U.

The fix is partly cultural (treat snapshot-only diffs with the same scrutiny as code diffs in review) and partly technical (don't snapshot things that change for reasons unrelated to the test's intent).

When Snapshots Earn Their Keep

  • Error messages: short, stable, the exact text matters. expect(formatError(input)).toMatchInlineSnapshot('"Expected number, got string"') is fine.
  • Normalized JSON contracts: small response shapes you want locked. Pair with a serializer that strips dates / ids so the snapshot is deterministic.
  • CLI output: command-line tools that print specific lines benefit from snapshot tests on the rendered output.

When They Don't

  • Large HTML trees from React components: every Tailwind class change breaks the snapshot. The signal-to-noise drops fast.
  • Output containing dates, ids, hashes: non-deterministic by default. Add a serializer or assert on shape, not the snapshot.
  • Anything you couldn't write a focused assertion for. A snapshot is not a substitute for thinking about what should be true.
Default to no snapshot. If you can write expect(thing.foo).toBe('bar'), do that instead. Snapshots are a fallback when the output is genuinely unwieldy AND you genuinely need to lock it down. Both halves matter — unwieldy alone is not a reason.

Code

Inline snapshot — small, stable, visible in review·typescript
import { describe, it, expect } from 'vitest';
import { formatValidationError } from './errors';

describe('formatValidationError', () => {
  it('renders the wrong-type case', () => {
    expect(formatValidationError({ field: 'age', expected: 'number', got: 'string' }))
      .toMatchInlineSnapshot('"age: expected number, got string"');
  });

  it('renders the out-of-range case', () => {
    expect(formatValidationError({ field: 'age', expected: '0..120', got: -5 }))
      .toMatchInlineSnapshot('"age: expected 0..120, got -5"');
  });
});
File snapshot — separate file, easier to ignore in review·typescript
// File snapshot — produces __snapshots__/format.test.ts.snap
import { describe, it, expect } from 'vitest';
import { renderInvoice } from './invoice';

describe('renderInvoice', () => {
  it('matches the canonical structure', () => {
    const invoice = { id: 'INV-001', total: 99.99, currency: 'USD' };
    // Stored in __snapshots__/. Inspect the file after the first run.
    expect(renderInvoice(invoice)).toMatchSnapshot();
  });
});

// __snapshots__/invoice.test.ts.snap (auto-generated)
//
// exports[`renderInvoice > matches the canonical structure 1`] = `
// {
//   "id": "INV-001",
//   "lineItems": [],
//   "subtotal": 99.99,
//   "tax": 0,
//   "total": 99.99
// }
// `;
Updating snapshots — and why pressing `u` is muscle memory you should distrust·bash
# When a snapshot test fails because output legitimately changed:
npx vitest -u

# Or update only a specific file:
npx vitest src/lib/invoice.test.ts -u

# Watch mode: press 'u' to update failed snapshots
#                press 'i' to update inline snapshots only
Anti-pattern — snapshot of a whole rendered component·typescript
// ANTI-PATTERN — don't do this
import { render } from '@testing-library/react';
import { Dashboard } from './dashboard';

it('renders the dashboard', () => {
  const { container } = render(<Dashboard />);
  expect(container.innerHTML).toMatchSnapshot();
  // ⚠️ This will break on every Tailwind class change, every text tweak,
  //    every prop reshuffling. Nobody will read the diff. They'll press U.
  //    Write focused assertions instead — toBeInTheDocument(), toHaveRole(),
  //    etc. (We'll cover those in the components track.)
});

External links

Exercise

Find one snapshot test in your codebase (or any open-source project — search GitHub for toMatchSnapshot). Read it. Ask: when this snapshot changes, will someone read the diff carefully, or will they press U and move on? If the latter, write a replacement using focused assertions — toBe, toContain, toMatchObject. Compare the two: which one would catch a regression you'd care about?
Hint
The replacement assertion is often longer than the snapshot. That's the point — the explicit version says what matters, the snapshot says only 'whatever was here last time.' Length is the cost of clarity.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.