Snapshot Testing Best Practices: Keeping Snapshots Maintainable

Snapshot Testing Best Practices: Keeping Snapshots Maintainable

Snapshots that start as a safety net often become a maintenance burden. Test files fill with .snap files that nobody reviews. Developers run jest -u reflexively when tests fail. The snapshots grow to thousands of lines covering third-party component internals.

This guide covers the practices that keep snapshot tests small, reviewable, and genuinely useful over time.

Practice 1: Keep Snapshots Small and Focused

The single most important snapshot best practice: test only what you care about.

Full-component snapshots capture everything — including elements, attributes, and nested children you don't care about. When any part of the tree changes, the snapshot fails. This leads to false positives and reflexive jest -u runs.

// Bad — snapshots the entire rendered tree
test('UserCard renders correctly', () => {
  const { container } = render(<UserCard user={user} />);
  expect(container).toMatchSnapshot(); // hundreds of lines
});

// Good — snapshots the specific element you care about
test('UserCard shows user name and role badge', () => {
  const { getByTestId } = render(<UserCard user={user} />);
  expect(getByTestId('user-name')).toMatchInlineSnapshot(`
    <span data-testid="user-name">Jane Doe</span>
  `);
  expect(getByTestId('role-badge')).toMatchInlineSnapshot(`
    <span class="badge badge-admin" data-testid="role-badge">Admin</span>
  `);
});

A good snapshot is 5-30 lines. If yours is longer, scope it down.

Practice 2: Prefer Inline Snapshots for Small Output

Inline snapshots embed the expected value directly in the test file, eliminating the need to open a separate .snap file to understand what a test is checking:

// Inline snapshot — everything visible in one place
test('formats price with currency symbol', () => {
  expect(formatPrice(29.99, 'USD')).toMatchInlineSnapshot(`"$29.99"`);
  expect(formatPrice(29.99, 'EUR')).toMatchInlineSnapshot(`"€29.99"`);
  expect(formatPrice(29.99, 'GBP')).toMatchInlineSnapshot(`"£29.99"`);
});

When to use inline: Output is under 20 lines, and you want the expected value visible in the test.

When to use file snapshots: Output is larger (20-50 lines) and would clutter the test file.

Never use file snapshots when: The output is over 50 lines — scope it down instead.

Practice 3: Use Descriptive Test Names

The snapshot file key is derived from the test name. Descriptive test names make snapshot files navigable:

// Bad — vague test name
test('renders correctly', () => {  // → "renders correctly 1"
  expect(component).toMatchSnapshot();
});

// Good — specific test name
test('renders error state with retry button when fetch fails', () => {
  // → "renders error state with retry button when fetch fails 1"
  expect(component).toMatchSnapshot();
});

When you open the .snap file, you should be able to understand what each snapshot represents without reading the test.

Practice 4: Mask Dynamic Values

Snapshots with dynamic values (timestamps, random IDs, session tokens) fail on every run because the values change. Mask them explicitly:

// Bad — includes dynamic timestamp
test('creates user record', async () => {
  const user = await createUser({ name: 'Alice' });
  expect(user).toMatchSnapshot(); // fails because createdAt changes
});

// Good — mask dynamic values with asymmetric matchers
test('creates user record', async () => {
  const user = await createUser({ name: 'Alice' });
  expect(user).toMatchSnapshot({
    id: expect.any(Number),
    createdAt: expect.any(String),
    updatedAt: expect.any(String),
    sessionToken: expect.any(String),
    // Static fields matched exactly
    name: 'Alice',
    role: 'user',
    emailVerified: false,
  });
});

Asymmetric matchers tell Jest "expect something of this type here" rather than matching the exact value.

Practice 5: Never Update Snapshots Without Reading the Diff

This is the most common failure mode. When tests fail, developers run jest -u to make the red tests green without understanding what changed.

Establish a team norm: snapshots are only updated after the diff is reviewed and understood.

Make the diff visible by running Jest with the diff flag:

# See the full diff before deciding to update
jest --verbose 2>&1 <span class="hljs-pipe">| grep -A 20 <span class="hljs-string">"Snapshot name:"

<span class="hljs-comment"># Update only after reviewing
jest -u

Add this to your PR description template:

## Snapshot Changes
- [ ] Reviewed all snapshot diffs — changes are intentional
- [ ] No unexpected output changes in updated snapshots

Practice 6: Organize Snapshot Files

Jest auto-creates __snapshots__/ directories next to test files. Keep this structure and avoid moving .snap files manually:

src/
  components/
    Button/
      Button.test.tsx
      __snapshots__/
        Button.test.tsx.snap
    UserCard/
      UserCard.test.tsx
      __snapshots__/
        UserCard.test.tsx.snap

Don't consolidate all snapshots into one file — the auto-generated structure ties each snapshot to its test file, making git diffs readable.

Practice 7: Delete Obsolete Snapshots Regularly

When tests are deleted or renamed, their snapshots become orphaned. Orphaned snapshots accumulate and mislead:

# See what obsolete snapshots exist
jest --verbose --ci 2>&1 <span class="hljs-pipe">| grep <span class="hljs-string">"obsolete"

<span class="hljs-comment"># Remove them (also removes if tests are renamed)
jest -u

Make snapshot cleanup part of your regular test maintenance. A monthly jest -u run specifically to clean up obsolete snapshots prevents the accumulation.

Practice 8: Use Custom Serializers for Cleaner Output

The default Jest serializer produces verbose output for DOM nodes. Custom serializers can make snapshots more readable:

jest-serializer-html

Formats HTML more readably:

npm install --save-dev jest-serializer-html
// jest.config.js
module.exports = {
  snapshotSerializers: ['jest-serializer-html'],
};

Filtering Irrelevant Attributes

If your test output includes internal framework attributes you don't care about (like data-reactroot, generated class names), filter them with a custom serializer:

// jest.setup.js
expect.addSnapshotSerializer({
  test: (val) => val && val.nodeType === 1,  // DOM elements
  print: (val, serialize) => {
    // Remove data-testid from snapshots (test-only attribute)
    val.removeAttribute('data-testid');
    return serialize(val);
  },
});

This keeps snapshots focused on what matters semantically, not test infrastructure.

Practice 9: Don't Snapshot Third-Party Component Internals

When your component renders a third-party UI library component, the snapshot includes that library's internal DOM structure. This creates two problems:

  1. The snapshot becomes enormous and unreadable
  2. Updating the UI library version fails your snapshots even when your code is correct

Scope snapshots to exclude third-party internals:

// Bad — includes all of MUI Button's internals
test('renders submit button', () => {
  const { container } = render(<CheckoutButton />);
  expect(container).toMatchSnapshot();
  // Includes 50+ lines of MUI internals
});

// Good — only test what you own
test('renders submit button with correct label', () => {
  const { getByRole } = render(<CheckoutButton />);
  const button = getByRole('button', { name: 'Complete Purchase' });
  expect(button).toBeInTheDocument();
  expect(button).toBeEnabled();
});

Practice 10: Review Snapshots in PRs Like Any Other Code

Snapshot updates show up in pull request diffs. Treat them with the same attention you'd give a code change:

Red flags in snapshot reviews:

  • Snapshot that grew from 10 lines to 200 lines — too large
  • New class names from a CSS-in-JS library — testing implementation details
  • Timestamp or ID changes — dynamic values not being masked
  • Snapshot updated for a PR that says "no frontend changes" — something changed that shouldn't have

Green flags:

  • Snapshot diff matches the described feature change exactly
  • Small, focused diff that clearly corresponds to the PR's intent
  • Updated inline snapshot directly next to its test

Make snapshot review an explicit step in your PR review checklist.

Practice 11: Migrate Large Snapshots to Explicit Assertions

When you inherit a codebase with large, unmaintainable snapshots, don't delete them all at once. Migrate incrementally:

  1. Identify tests with snapshots over 50 lines
  2. Write explicit assertions for the important behaviors these tests guard
  3. Delete the large snapshot test once the explicit tests are green
  4. Repeat

This is lower risk than deleting all snapshots and hoping your explicit tests cover everything.

# Find tests with large snapshots
find . -name <span class="hljs-string">"*.snap" -<span class="hljs-built_in">exec <span class="hljs-built_in">wc -l {} + <span class="hljs-pipe">| <span class="hljs-built_in">sort -rn <span class="hljs-pipe">| <span class="hljs-built_in">head -20

Start with the largest files — they have the most ROI.

Summary

Maintainable snapshot testing comes down to discipline around six things:

Practice What It Prevents
Small, focused snapshots Brittle tests that fail for unrelated reasons
Inline snapshots for small output Context-switching to read .snap files
Masking dynamic values Flaky tests that fail due to timestamps/IDs
Reading diffs before updating Approving regressions without noticing
Cleaning up obsolete snapshots Misleading test coverage
No third-party component internals Snapshot breakage on library updates

Teams that follow these practices use snapshots effectively. Teams that don't end up with a snapshot tax — hundreds of tests that fail for irrelevant reasons, get updated without thought, and provide no real protection against regressions.

Read more