Snapshot Testing in React: Benefits, Pitfalls, and Best Practices

Shunku

Snapshot testing captures a component's rendered output and compares it against a stored reference. While powerful for detecting unexpected changes, it's often misused. Let's explore how to use snapshots effectively.

What is Snapshot Testing?

A snapshot test renders a component and saves its output to a file. Future test runs compare against this stored snapshot:

flowchart TD
    A[Run Test] --> B{Snapshot Exists?}
    B -->|No| C[Create Snapshot]
    B -->|Yes| D[Compare Output]
    D --> E{Match?}
    E -->|Yes| F[Test Passes]
    E -->|No| G[Test Fails]
    G --> H{Intentional Change?}
    H -->|Yes| I[Update Snapshot]
    H -->|No| J[Fix Code]

    style F fill:#10b981,color:#fff
    style G fill:#ef4444,color:#fff

Basic Snapshot Test

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

test('renders user card', () => {
  const { container } = render(
    <UserCard
      user={{
        name: 'Alice',
        email: 'alice@example.com',
        avatar: 'avatar.jpg',
      }}
    />
  );

  expect(container).toMatchSnapshot();
});

This creates a snapshot file (__snapshots__/UserCard.test.tsx.snap):

exports[`renders user card 1`] = `
<div>
  <div
    class="user-card"
  >
    <img
      alt="Alice"
      class="avatar"
      src="avatar.jpg"
    />
    <div
      class="user-info"
    >
      <h3>
        Alice
      </h3>
      <p>
        alice@example.com
      </p>
    </div>
  </div>
</div>
`;

Inline Snapshots

Instead of separate files, store snapshots inline in test files:

test('renders greeting', () => {
  const { container } = render(<Greeting name="World" />);

  expect(container).toMatchInlineSnapshot(`
    <div>
      <h1>
        Hello, World!
      </h1>
    </div>
  `);
});

Benefits of inline snapshots:

  • Easier to review in code review
  • No need to find separate snapshot files
  • Changes are visible in the same file

When to Use Snapshots

Good Use Cases

flowchart LR
    subgraph Good["Good for Snapshots"]
        A[Static components]
        B[Generated markup]
        C[Configuration objects]
        D[Error messages]
    end

    subgraph Bad["Bad for Snapshots"]
        E[Dynamic content]
        F[Large components]
        G[Behavior testing]
        H[Frequently changing UI]
    end

    style Good fill:#10b981,color:#fff
    style Bad fill:#ef4444,color:#fff

1. Small, Static Components

// Good: Simple, presentational component
test('renders badge', () => {
  const { container } = render(<Badge type="success">Approved</Badge>);
  expect(container).toMatchInlineSnapshot(`
    <div>
      <span class="badge badge-success">
        Approved
      </span>
    </div>
  `);
});

2. Configuration/Data Objects

test('generates correct config', () => {
  const config = generateChartConfig({
    type: 'bar',
    data: [1, 2, 3],
    title: 'Sales',
  });

  expect(config).toMatchInlineSnapshot(`
    {
      "options": {
        "responsive": true,
        "title": {
          "display": true,
          "text": "Sales",
        },
      },
      "type": "bar",
    }
  `);
});

3. Error Messages

test('shows validation error', () => {
  const result = validateForm({ email: 'invalid' });

  expect(result.errors).toMatchInlineSnapshot(`
    [
      {
        "field": "email",
        "message": "Please enter a valid email address",
      },
    ]
  `);
});

When NOT to Use Snapshots

1. Large Components

// Bad: Snapshot is too large to meaningfully review
test('renders entire page', () => {
  const { container } = render(<DashboardPage />);
  expect(container).toMatchSnapshot(); // 500+ lines!
});

// Better: Test specific behaviors
test('shows user name in header', () => {
  render(<DashboardPage user={{ name: 'Alice' }} />);
  expect(screen.getByText('Alice')).toBeInTheDocument();
});

2. Dynamic Content

// Bad: Snapshot will fail every time
test('renders timestamp', () => {
  const { container } = render(<Comment createdAt={new Date()} />);
  expect(container).toMatchSnapshot(); // Contains current date!
});

// Better: Mock the date or test the format
test('shows formatted date', () => {
  render(<Comment createdAt={new Date('2024-01-15')} />);
  expect(screen.getByText('January 15, 2024')).toBeInTheDocument();
});

3. Testing Behavior

// Bad: Snapshots can't test interactions
test('button click', () => {
  const { container } = render(<Counter />);
  fireEvent.click(screen.getByRole('button'));
  expect(container).toMatchSnapshot(); // Just shows UI state

// Better: Test the behavior
test('increments count on click', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  await user.click(screen.getByRole('button'));

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

Snapshot Anti-Patterns

1. Snapshot Everything

// Anti-pattern: Snapshotting every test
describe('UserList', () => {
  test('renders empty', () => {
    const { container } = render(<UserList users={[]} />);
    expect(container).toMatchSnapshot();
  });

  test('renders with users', () => {
    const { container } = render(<UserList users={users} />);
    expect(container).toMatchSnapshot();
  });

  test('renders loading', () => {
    const { container } = render(<UserList loading />);
    expect(container).toMatchSnapshot();
  });
});

// Better: Explicit assertions
describe('UserList', () => {
  test('shows empty message when no users', () => {
    render(<UserList users={[]} />);
    expect(screen.getByText('No users found')).toBeInTheDocument();
  });

  test('displays all users', () => {
    render(<UserList users={[{ id: 1, name: 'Alice' }]} />);
    expect(screen.getByText('Alice')).toBeInTheDocument();
  });

  test('shows loading spinner', () => {
    render(<UserList loading />);
    expect(screen.getByRole('progressbar')).toBeInTheDocument();
  });
});

2. Blindly Updating Snapshots

# Dangerous! Updates all failing snapshots without review
npm test -- -u

Always review snapshot changes before updating:

# Interactive mode - review each change
npm test -- --watch
# Then press 'i' to interactively update

3. Testing Implementation Details

// Anti-pattern: Snapshot includes CSS classes, internal structure
expect(container).toMatchInlineSnapshot(`
  <div class="sc-hKMtZM jPSxnS user-card__wrapper--active">
    <div class="sc-bdVaJa bULBFu">
      ...
    </div>
  </div>
`);

// Better: Test meaningful output
expect(screen.getByRole('article')).toHaveTextContent('Alice');

Custom Serializers

Create custom serializers to clean up snapshot output:

// Remove dynamic class names (styled-components, CSS modules)
expect.addSnapshotSerializer({
  test: (val) => typeof val === 'string' && val.match(/sc-[a-zA-Z]+/),
  print: (val) => val.replace(/sc-[a-zA-Z]+ [a-zA-Z]+/g, 'styled'),
});

// Simplify dates
expect.addSnapshotSerializer({
  test: (val) => val instanceof Date,
  print: (val) => `Date(${val.toISOString()})`,
});

Focused Snapshot Testing

Snapshot Specific Elements

test('renders user name correctly', () => {
  render(<UserCard user={{ name: 'Alice', email: 'alice@example.com' }} />);

  // Snapshot only the name element
  expect(screen.getByRole('heading')).toMatchInlineSnapshot(`
    <h3>
      Alice
    </h3>
  `);
});

Property Matchers

Handle dynamic values in object snapshots:

test('creates user with timestamp', () => {
  const user = createUser({ name: 'Alice' });

  expect(user).toMatchSnapshot({
    id: expect.any(String),
    createdAt: expect.any(Date),
    name: 'Alice',
  });
});

Snapshot Testing Strategy

Component Snapshot Matrix

Component Type Snapshot? Better Alternative
Icon/Badge Yes -
Button Maybe Test click behavior
Form No Test validation, submission
List No Test items, empty state
Modal No Test open/close, content
Page No Test key elements

Combining Approaches

describe('NotificationBadge', () => {
  // Snapshot for visual structure
  test('renders correctly', () => {
    const { container } = render(<NotificationBadge count={5} />);
    expect(container).toMatchInlineSnapshot(`
      <div>
        <span class="badge">
          5
        </span>
      </div>
    `);
  });

  // Explicit tests for behavior
  test('hides when count is zero', () => {
    render(<NotificationBadge count={0} />);
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
  });

  test('shows 99+ for large counts', () => {
    render(<NotificationBadge count={150} />);
    expect(screen.getByText('99+')).toBeInTheDocument();
  });
});

Updating Snapshots

In Watch Mode

Press `u` to update all failing snapshots
Press `i` to update interactively

Command Line

# Update all snapshots
npm test -- -u

# Update specific test file
npm test UserCard.test.tsx -- -u

CI/CD Consideration

# Fail CI if snapshots are outdated
test:
  script:
    - npm test -- --ci

The --ci flag prevents accidental snapshot updates in CI.

Summary

Aspect Recommendation
Size Keep snapshots small (<50 lines)
Scope Snapshot presentational components
Updates Review every change, never blindly update
Alternative Prefer explicit assertions for behavior
Inline Use inline snapshots when possible

Key takeaways:

  • Snapshots are best for small, static components
  • Avoid snapshotting entire pages or dynamic content
  • Always review snapshot changes before updating
  • Prefer explicit assertions for testing behavior
  • Use inline snapshots for easier code review
  • Don't use snapshots as a substitute for meaningful tests

Snapshot testing is a tool, not a strategy. Use it sparingly and intentionally to catch unexpected visual regressions, not as a replacement for thoughtful testing.

References