Jest Snapshot Testing: Complete Guide to Writing Reliable Snapshots

Jest Snapshot Testing: Complete Guide to Writing Reliable Snapshots

Jest snapshot testing is simultaneously the most misused testing feature in the React ecosystem and genuinely useful when applied correctly. Teams either avoid it entirely (too fragile) or over-use it (snapshot everything and update blindly). Neither is right.

This guide covers how snapshot testing actually works, when it provides real value, how to write snapshots that catch regressions without creating noise, and the patterns that prevent snapshot tests from becoming a maintenance burden.

What Snapshot Testing Is

When you call expect(something).toMatchSnapshot(), Jest:

  1. Serializes something to a string
  2. If no .snap file exists: creates one and passes the test
  3. If a .snap file exists: compares the serialized output to the stored string

The snapshot file is committed to version control. It represents the "approved" output of the component or function.

When the test runs in CI and the output has changed, the test fails. You then decide:

  • Intentional change: run jest --updateSnapshot to update the file, commit the updated snapshot
  • Unintended regression: fix the code, don't update the snapshot

This is the entire model. Simple in theory; problematic in practice when applied without discipline.

When Snapshots Are Valuable

Snapshots are useful for:

1. Serialized output that's hard to assert manually

Serializing a complex object, a deeply nested data structure, or the output of a code generator:

test('schema generator produces correct output', () => {
  const schema = generateSchema(model);
  expect(schema).toMatchSnapshot();
  // More meaningful than asserting 30 individual properties
});

test('markdown renderer produces correct HTML', () => {
  const html = renderMarkdown('## Hello\n\nParagraph with **bold** text.');
  expect(html).toMatchSnapshot();
});

2. Catching accidental changes to stable output

If a component's rendered output shouldn't change between releases except intentionally:

test('ConfirmDialog renders correctly', () => {
  const { container } = render(
    <ConfirmDialog
      title="Delete account"
      message="This action cannot be undone."
      onConfirm={jest.fn()}
      onCancel={jest.fn()}
    />
  );
  expect(container).toMatchSnapshot();
});

3. Testing serializers and formatters

test('formatCurrency formats USD correctly', () => {
  const cases = [0, 1, 1.5, 1000, 1000000.99];
  expect(cases.map(formatCurrency)).toMatchSnapshot();
});

When Snapshots Are a Bad Idea

1. Dynamic content

// Bad — snapshot will fail on every run
test('renders current date', () => {
  const { container } = render(<LastUpdated />);
  expect(container).toMatchSnapshot();
  // The date changes, the snapshot changes, the test noise is endless
});

2. As a substitute for meaningful assertions

// Bad — what does this test actually verify?
test('LoginForm renders', () => {
  const { container } = render(<LoginForm />);
  expect(container).toMatchSnapshot();
  // It just confirms that the component renders something
  // It will fail when any CSS class, aria-label, or attribute changes
});

// Good — test what matters
test('LoginForm disables submit while loading', () => {
  const { getByRole } = render(<LoginForm loading={true} />);
  expect(getByRole('button', { name: 'Log in' })).toBeDisabled();
});

3. Large component trees

Snapshots of entire page components are enormous. They fail when anything anywhere in the tree changes, making it impossible to see what actually changed:

// Bad — snapshot of the entire page
test('DashboardPage renders', () => {
  const { container } = render(<DashboardPage />);
  expect(container).toMatchSnapshot();
  // Any change anywhere fails this test
  // The diff is 800 lines and unmeaningful
});

Snapshot small, focused components. For large trees, use targeted assertions.

Inline Snapshots

Inline snapshots are stored in the test file itself, not in a separate .snap file:

test('user greeting renders correctly', () => {
  const { container } = render(<UserGreeting name="Alice" />);
  expect(container).toMatchInlineSnapshot(`
    <div>
      <div
        class="greeting"
      >
        Hello, Alice!
      </div>
    </div>
  `);
});

When you run jest --updateSnapshot, Jest updates the inline string in the test file directly.

Advantages of inline snapshots:

  • The snapshot is visible in the test — no context switching to a .snap file
  • Code review shows the snapshot diff alongside the test change
  • Cleaner for small, focused snapshots

Disadvantages:

  • Clutters the test file for large snapshots
  • Harder to manage when the snapshot is large

Use inline snapshots for compact outputs (1-15 lines), file snapshots for larger outputs.

Snapshot Serializers

Jest uses serializers to convert values to snapshot strings. The default serializers handle plain objects, arrays, React elements (via react-test-renderer), and HTML (via @testing-library/jest-dom).

Custom serializer for specific types

// jest.config.js
module.exports = {
  snapshotSerializers: ['@emotion/jest/serializer'] // for emotion CSS-in-JS
};

Inline custom serializer

expect.addSnapshotSerializer({
  test: (val) => val && val.type === 'GraphQLError',
  print: (val, serialize) => `GraphQLError: ${val.message}\n  Path: ${val.path?.join(' > ')}`
});

test('GraphQL error serializes cleanly', () => {
  const error = new GraphQLError('Field not found', { path: ['users', 'id'] });
  expect(error).toMatchInlineSnapshot(`
    GraphQLError: Field not found
      Path: users > id
  `);
});

Custom serializers transform snapshots into human-readable forms, making diffs actually meaningful.

Avoiding Snapshot Noise

Masking dynamic values

// Component with a generated ID
function Tooltip({ content }) {
  const id = useId(); // React's built-in hook
  return (
    <div>
      <span aria-describedby={id}>{/* trigger */}</span>
      <div id={id} role="tooltip">{content}</div>
    </div>
  );
}

The id changes between renders. Options:

Option 1: Mock the dynamic value

jest.mock('react', () => ({
  ...jest.requireActual('react'),
  useId: () => 'test-id-1'
}));

Option 2: Use inline snapshot and replace dynamic parts

test('Tooltip renders correctly', () => {
  const { container } = render(<Tooltip content="Help text" />);
  // Replace the dynamic ID with a stable placeholder before snapshotting
  const stable = container.innerHTML.replace(/tooltip-[a-z0-9]+/g, 'tooltip-ID');
  expect(stable).toMatchInlineSnapshot(`
    "<span aria-describedby=\\"tooltip-ID\\">...</span>
    <div id=\\"tooltip-ID\\" role=\\"tooltip\\">Help text</div>"
  `);
});

Option 3: Don't snapshot dynamic attributes — assert them

test('Tooltip links trigger to tooltip', () => {
  const { getByRole } = render(<Tooltip content="Help text" />);
  const tooltip = getByRole('tooltip');
  const trigger = getByRole('...'); // whatever triggers the tooltip
  expect(trigger.getAttribute('aria-describedby')).toBe(tooltip.id);
});

Option 3 is often the best: test the relationship, not the specific ID value.

Filtering props from snapshots

// Third-party component that injects noisy props
import { ThirdPartyComponent } from 'library';

// In the test, render only what you control
test('MyComponent wraps ThirdPartyComponent correctly', () => {
  const { getByTestId } = render(<MyComponent />);
  // Assert on the behavior, not the third-party component's internals
  expect(getByTestId('my-container')).toMatchSnapshot();
});

Updating Snapshots

# Update all snapshots
jest --updateSnapshot

<span class="hljs-comment"># Update only specific test file
jest --updateSnapshot src/components/Button/Button.test.jsx

<span class="hljs-comment"># Interactive mode — review each change
jest --watch
<span class="hljs-comment"># Then press 'u' to update snapshots interactively

Always review snapshot diffs before committing. A blind jest --updateSnapshot defeats the entire purpose of snapshot testing. Before running the update command, read the diff — does it reflect an intentional change? If yes, update. If you don't know why it changed, investigate.

Snapshot Size Limits

Configure a warning threshold for large snapshots:

// jest.config.js
module.exports = {
  // Warn if snapshots exceed this size
  snapshotFormat: {
    maxLength: 20000
  }
};

If a snapshot is so large that you can't review its diff, it's too large. Break the component into smaller pieces and test them individually.

Testing with react-test-renderer vs Testing Library

There are two common approaches for React component snapshots:

react-test-renderer (older):

import renderer from 'react-test-renderer';

test('Button snapshot', () => {
  const tree = renderer.create(<Button variant="primary">Click</Button>).toJSON();
  expect(tree).toMatchSnapshot();
});

Testing Library (recommended):

import { render } from '@testing-library/react';

test('Button snapshot', () => {
  const { container } = render(<Button variant="primary">Click</Button>);
  expect(container.firstChild).toMatchSnapshot();
});

Testing Library is recommended because:

  • It renders into a real DOM (jsdom), closer to how the browser works
  • It discourages testing implementation details
  • Snapshots capture the actual HTML output, not a React element tree

Snapshot Testing in a Larger Test Strategy

Snapshots complement, not replace, other test types:

Test type What it covers
Unit tests Business logic, pure functions, component behavior
Snapshot tests Stable component output, serialized data structures
Integration tests Component interactions, state management
E2E tests Full user flows
Visual regression (Percy/Chromatic) Pixel-level appearance in real browsers

Note: Jest snapshots don't test visual appearance — they test rendered HTML/JSX structure. For pixel-level visual regression, use a dedicated visual testing tool.

Example: Good Snapshot Test Suite

A well-scoped snapshot test suite for a NotificationBadge component:

import { render } from '@testing-library/react';
import { NotificationBadge } from './NotificationBadge';

describe('NotificationBadge', () => {
  it('renders with a count', () => {
    const { container } = render(<NotificationBadge count={5} />);
    expect(container.firstChild).toMatchInlineSnapshot(`
      <span
        aria-label="5 notifications"
        class="badge badge--default"
      >
        5
      </span>
    `);
  });

  it('renders count > 99 as 99+', () => {
    const { container } = render(<NotificationBadge count={150} />);
    expect(container.firstChild).toMatchInlineSnapshot(`
      <span
        aria-label="150 notifications"
        class="badge badge--default"
      >
        99+
      </span>
    `);
  });

  it('renders in urgent variant when count is high', () => {
    const { container } = render(<NotificationBadge count={10} urgent />);
    expect(container.firstChild).toMatchInlineSnapshot(`
      <span
        aria-label="10 notifications"
        class="badge badge--urgent"
      >
        10
      </span>
    `);
  });
});

These snapshots are small, focused, and meaningful. The diff is readable. Reviewing an update takes 30 seconds.

Summary

Snapshot testing's reputation for being fragile comes from misuse: snapshotting full pages, not reviewing updates, and using snapshots instead of targeted assertions.

Used correctly — on small, stable components and serialized output — snapshots catch real regressions with minimal maintenance overhead. The discipline is:

  • Small snapshots, not whole page trees
  • Inline snapshots for small outputs
  • Always review updates before committing
  • Never snapshot dynamic values
  • Complement with targeted assertions for behavior, not appearance

Read more

Testing Atlantis Terraform PR Automation: Workflows, Plan Verification, and Policy Enforcement

Testing Atlantis Terraform PR Automation: Workflows, Plan Verification, and Policy Enforcement

Atlantis automates Terraform plan and apply through pull requests. But Atlantis itself needs testing: workflow configuration, plan output validation, policy enforcement, and server health checks. This guide covers testing Atlantis workflows locally with atlantis-local, validating plan outputs with custom scripts, enforcing Terraform policies with OPA and Conftest, and monitoring Atlantis

By HelpMeTest