Skip to main content

Snapshot & Visual Testing

Snapshot testing captures the rendered output of a component (or any serialisable value) and saves it to a file. Subsequent test runs compare the current output against the saved snapshot — if they differ, the test fails. This sounds like a safety net but can become a maintenance burden if used carelessly.

This chapter covers Jest's built-in snapshot mechanism, its trade-offs, and how Storybook can complement or replace snapshots for visual component testing.

Jest Snapshot Testing

How It Works

On the first run, toMatchSnapshot() writes the snapshot to a .snap file next to the test. On every subsequent run, it compares the current value to the saved file.

// src/components/Badge.test.tsx
import { render } from '@testing-library/react';
import { Badge } from './Badge';

it('renders a success badge', () => {
const { container } = render(<Badge status="success">Active</Badge>);
expect(container).toMatchSnapshot();
});

First run creates __snapshots__/Badge.test.tsx.snap:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`renders a success badge 1`] = `
<div>
<span
class="badge badge--success"
>
Active
</span>
</div>
`;

If you change the component — even adding a CSS class — the test fails until you update the snapshot.

Updating Snapshots

# Update all snapshots
npx jest --updateSnapshot
npx jest -u

# Update snapshots for a specific file
npx jest Badge.test.tsx --updateSnapshot

Updating snapshots should be a deliberate decision, not a reflex. Always read the diff before updating to confirm the change is intentional.

Inline Snapshots

Instead of writing to a .snap file, toMatchInlineSnapshot() writes the snapshot directly into the test source file. This keeps the expected output close to the assertion:

it('renders a warning badge', () => {
const { container } = render(<Badge status="warning">Expiring soon</Badge>);
expect(container).toMatchInlineSnapshot(`
<div>
<span
class="badge badge--warning"
>
Expiring soon
</span>
</div>
`);
});

Inline snapshots are self-updating: jest -u rewrites the string in the source file. They are better than external snapshots because reviewers can see the expected HTML in the pull request diff.

Snapshot Testing for Non-HTML Values

Snapshots are not limited to React output. They work on any serialisable JavaScript value:

// Snapshot an API response shape
it('API response has the expected shape', () => {
const response = buildApiResponse({ status: 'ok', count: 3 });
expect(response).toMatchInlineSnapshot(`
{
"count": 3,
"status": "ok",
"timestamp": Any<String>,
}
`);
});

For dynamic values (timestamps, generated IDs), use asymmetric matchers:

expect(response).toMatchSnapshot({
id: expect.any(String),
createdAt: expect.any(String),
});

This lets you snapshot the stable parts of the response while ignoring volatile fields.

The Pros and Cons of Snapshots

When snapshots help

SituationWhy snapshots work
Asserting a serialiser / template outputYou want to catch any change to the formatted string
Stable, rarely-changing components (icons, atoms)Changes are infrequent and always meaningful
Complex data transformation outputEasier to read than writing out every field
Catching accidental regressions in a UI libraryAny diff needs human review

When snapshots hurt

SituationWhy snapshots fail
Large component treesThe snapshot is hundreds of lines; reviewers approve jest -u without reading
Frequently updated componentsConstant snapshot updates create noise
Snapshots that "test" third-party library outputYou end up snapshotting React internals
Tests that should be asserting behaviourA snapshot cannot tell you why a change matters

The Snapshot Anti-Pattern

The most common snapshot mistake is snapshotting an entire page component:

// BAD: a 2000-line snapshot no one reads
it('renders the dashboard', () => {
const { container } = render(<Dashboard />);
expect(container).toMatchSnapshot(); // ← this is a liability
});

Every time any child component changes, this snapshot breaks. Developers run jest -u without reviewing. The snapshot drifts from reality.

Better approach: test behaviour explicitly in RTL, and snapshot only small, stable leaf components:

// GOOD: small, stable atom
it('renders a pill badge', () => {
const { container } = render(<PillBadge color="green">Active</PillBadge>);
expect(container.firstChild).toMatchInlineSnapshot(`
<span class="pill pill--green">Active</span>
`);
});

Storybook for Visual Testing

Storybook is a tool for developing and documenting UI components in isolation. Each "story" is a component rendered with a specific set of props. Stories serve as:

  • A living component catalogue
  • A manual QA surface
  • A foundation for automated visual regression tests

Setting Up Storybook

npx storybook@latest init

This detects your framework (React, Vue, Svelte) and configures Storybook automatically.

Writing Stories

// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
component: Button,
title: 'Components/Button',
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger'],
},
},
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
args: {
variant: 'primary',
children: 'Click me',
},
};

export const Secondary: Story = {
args: {
variant: 'secondary',
children: 'Cancel',
},
};

export const Danger: Story = {
args: {
variant: 'danger',
children: 'Delete account',
},
};

export const Disabled: Story = {
args: {
variant: 'primary',
children: 'Unavailable',
disabled: true,
},
};

Run the Storybook dev server:

npm run storybook

Storybook Interaction Tests

The @storybook/addon-interactions addon lets you write play functions that simulate user interactions inside a story:

import { userEvent, within } from '@storybook/test';

export const FilledForm: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

await userEvent.type(
canvas.getByLabelText('Email'),
'alice@example.com'
);
await userEvent.type(
canvas.getByLabelText('Password'),
'secret123'
);
await userEvent.click(canvas.getByRole('button', { name: 'Log in' }));

await expect(
canvas.getByText('Login successful')
).toBeInTheDocument();
},
};

These play functions run in the browser and can be executed as part of a CI pipeline with storybook test.

Storybook Test Runner

The Storybook test runner (backed by Playwright) runs all stories headlessly and executes their play functions:

npm install --save-dev @storybook/test-runner
{
"scripts": {
"test:storybook": "test-storybook"
}
}

This gives you automated interaction testing of every story without writing a separate test file.

Visual Regression Testing with Chromatic

Chromatic is a cloud service built by the Storybook team. It renders every story across browsers, takes screenshots, and flags pixel-level differences for human review.

npm install --save-dev chromatic
npx chromatic --project-token=<your-token>

In CI:

# .github/workflows/chromatic.yml
- name: Publish to Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}

The workflow:

  1. Chromatic runs on every PR
  2. If a story's screenshot changes, Chromatic asks for review
  3. A human approves intended changes; unintended ones are flagged as bugs

This workflow is the industry standard for catching visual regressions in component libraries.

Choosing the Right Tool

NeedTool
Asserting component renders without crashingRTL render + toBeInTheDocument
Testing component behaviour (clicks, forms)RTL + userEvent
Preventing unintended HTML structure changes in small atomsInline snapshot
Visual documentation and manual QAStorybook stories
Automated interaction testing from storiesStorybook test runner
Visual regression across browsersChromatic (or Percy / Applitools)

The practical default for most applications: use RTL for all behaviour tests, Storybook stories for documentation and manual review, and inline snapshots sparingly for stable leaf components. Add visual regression tools when you are maintaining a shared component library or a design system.