For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt, and this page is available as Markdown at https://nhanbe.id.vn/testing/beginner-guide/09-snapshot-and-visual-testing.md.

Jest snapshot testing pros and cons, updating snapshots, when to use them, and an introduction to Storybook for visual component testing.

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, ignoring the volatile timestamp field
it('API response has the expected shape', () => {
    const response = buildApiResponse({ status: 'ok', count: 3 });
    expect(response).toMatchInlineSnapshot(
        { timestamp: expect.any(String) },
        `
        {
          "count": 3,
          "status": "ok",
          "timestamp": Any<String>,
        }
    `,
    );
});

Property matchers are passed as the first argument; each matched field is serialised in the snapshot as e.g. Any<String>. The same form works with toMatchSnapshot:

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'),
            '[email protected]'
        );
        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. As of Storybook 9, Vite-powered projects (React/Vue/Svelte with the Vite builder) should use @storybook/addon-vitest instead of the runner below; the Storybook test runner (test-storybook, see below) remains the supported path for non-Vite builders like Webpack or Angular.

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.