Storybook Visual Testing: Addons, Interaction Tests, and Snapshot Strategies

Storybook Visual Testing: Addons, Interaction Tests, and Snapshot Strategies

Storybook has evolved from a component development tool into a full testing platform. Beyond just rendering components in isolation, modern Storybook setups support interaction tests that simulate user behavior, accessibility checks, and visual snapshots — all from the same story definitions that you're already maintaining for documentation.

This guide covers the testing addon ecosystem, writing play functions for interaction tests, snapshot strategies, and when to choose local testing versus a cloud service like Chromatic.

The Storybook Testing Addon Ecosystem

Storybook's testing story starts with three addons working together:

@storybook/addon-interactions renders a timeline of user interactions in the Storybook UI and lets you step through them. It's built on top of @testing-library/user-event and uses MSW-compatible mocking.

@storybook/test-runner executes stories as actual tests in a headless browser (via Playwright). It runs all play functions and reports pass/fail to your terminal and CI.

@chromatic/storybook-addon (or the standalone Chromatic CLI) captures visual snapshots of every story for pixel-diff comparisons.

Install the core testing stack:

npm install --save-dev @storybook/test-runner @storybook/addon-interactions @storybook/test
npx playwright install

Add addon-interactions to your Storybook config:

// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
  ],
};

export default config;

Writing Play Functions

play functions are the core of Storybook interaction testing. They run after the story renders and can simulate clicks, typing, form submission — anything a user would do. Errors thrown inside play fail the story.

// components/LoginForm/LoginForm.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { LoginForm } from './LoginForm';

const meta: Meta<typeof LoginForm> = {
  title: 'Auth/LoginForm',
  component: LoginForm,
};

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

export const Default: Story = {};

export const FilledOut: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // Type into the email field
    await userEvent.type(
      canvas.getByLabelText('Email'),
      'user@example.com'
    );
    
    // Type into the password field
    await userEvent.type(
      canvas.getByLabelText('Password'),
      'securepassword'
    );
    
    // Verify the button is now enabled
    const submitButton = canvas.getByRole('button', { name: /sign in/i });
    await expect(submitButton).not.toBeDisabled();
  },
};

export const ValidationError: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // Submit without filling anything out
    await userEvent.click(
      canvas.getByRole('button', { name: /sign in/i })
    );
    
    // Both error messages should appear
    await expect(
      canvas.getByText('Email is required')
    ).toBeInTheDocument();
    
    await expect(
      canvas.getByText('Password is required')
    ).toBeInTheDocument();
  },
};

export const SubmittingState: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com');
    await userEvent.type(canvas.getByLabelText('Password'), 'password');
    await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
    
    // Loading spinner appears while submitting
    await expect(canvas.getByRole('progressbar')).toBeInTheDocument();
  },
};

The within(canvasElement) scoping is important — it constrains queries to the story's rendered output, preventing accidental matches in the Storybook chrome (sidebar, addons panel).

Running Stories as Tests

The test-runner executes every story's play function headlessly and reports results:

# Run all stories
npx test-storybook

# Run in watch mode
npx test-storybook --watch

# Run specific stories by path
npx test-storybook --testPathPattern="LoginForm"

For CI, start Storybook first, then run the test-runner against it:

# .github/workflows/storybook-tests.yml
name: Storybook Tests

on:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright
        run: npx playwright install chromium --with-deps

      - name: Build Storybook
        run: npm run build-storybook

      - name: Serve and test Storybook
        run: |
          npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
            "npx http-server storybook-static --port 6006 --silent" \
            "npx wait-on tcp:6006 && npx test-storybook"

Snapshot Strategies: Storyshots vs Chromatic vs test-runner

There are three main approaches to visual snapshots in Storybook:

Storyshots (Legacy)

@storybook/addon-storyshots renders each story to an HTML string and saves it as a Jest snapshot. These snapshots catch structural changes (element additions, attribute changes) but not visual changes like colors, spacing, or font rendering.

Storyshots is deprecated as of Storybook 8. The team recommends migrating to the test-runner or Chromatic.

Test-runner Screenshot Mode

The test-runner can take screenshots during story execution:

// .storybook/test-runner.ts
import type { TestRunnerConfig } from '@storybook/test-runner';

const config: TestRunnerConfig = {
  async postVisit(page, context) {
    // Take a screenshot of every story after play() completes
    const image = await page.screenshot();
    expect(image).toMatchSnapshot();
  },
};

export default config;

This uses Playwright's toMatchSnapshot() under the hood. Baselines are stored in __snapshots__/ and committed to the repository. The advantage: snapshots are local and free. The disadvantage: they're platform-specific (Linux baselines differ from macOS baselines).

Chromatic

Chromatic renders stories in a cloud browser environment with a collaborative review UI. It handles cross-browser differences, stores snapshot history, and integrates with PR workflows. The trade-off is cost (charged per snapshot) and dependency on an external service.

The right choice depends on team size and requirements: local test-runner snapshots work well for solo developers and small teams; Chromatic's review workflow becomes valuable when multiple people need to approve visual changes.

Mocking API Calls in Stories

Interaction tests that involve API calls need mocked responses. The @storybook/addon-mock or MSW (Mock Service Worker) integration handles this:

npm install --save-dev msw msw-storybook-addon
// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon';

initialize();

export const loaders = [mswLoader];

Now use MSW handlers directly in stories:

// components/UserProfile/UserProfile.stories.ts
import { http, HttpResponse } from 'msw';
import { UserProfile } from './UserProfile';

export const LoadedProfile: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/users/:id', () => {
          return HttpResponse.json({
            id: '1',
            name: 'Jane Smith',
            avatar: 'https://i.pravatar.cc/150?u=jane',
            role: 'Engineering Manager',
          });
        }),
      ],
    },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    // Wait for the async data to load and render
    await expect(
      await canvas.findByText('Jane Smith')
    ).toBeInTheDocument();
    await expect(canvas.getByText('Engineering Manager')).toBeInTheDocument();
  },
};

export const LoadingState: Story = {
  parameters: {
    msw: {
      handlers: [
        // Delay response to test loading state
        http.get('/api/users/:id', async () => {
          await new Promise(resolve => setTimeout(resolve, 10000));
          return HttpResponse.json({});
        }),
      ],
    },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await expect(canvas.getByRole('progressbar')).toBeInTheDocument();
  },
};

Composing Stories for Complex States

For components with many states, composing stories from smaller building blocks keeps things maintainable:

// Base story with shared args
const meta: Meta<typeof DataTable> = {
  title: 'Data/DataTable',
  component: DataTable,
  args: {
    columns: mockColumns,
  },
};

// Compose from base
export const Empty: Story = {
  args: { rows: [] },
};

export const WithData: Story = {
  args: { rows: mockRows },
};

// Compose interaction from WithData
export const WithSorting: Story = {
  ...WithData,
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement);
    
    await step('Sort by Name column', async () => {
      await userEvent.click(canvas.getByText('Name'));
    });
    
    await step('Verify ascending sort', async () => {
      const rows = canvas.getAllByRole('row').slice(1); // Skip header
      await expect(rows[0]).toHaveTextContent('Alice');
    });
    
    await step('Click again for descending', async () => {
      await userEvent.click(canvas.getByText('Name'));
    });
    
    await step('Verify descending sort', async () => {
      const rows = canvas.getAllByRole('row').slice(1);
      await expect(rows[0]).toHaveTextContent('Zara');
    });
  },
};

The step wrapper groups related actions in the Storybook interactions panel, making it easy to follow what each part of the test does.

Accessibility Testing in Stories

The @storybook/addon-a11y addon runs axe accessibility checks on every story:

npm install --save-dev @storybook/addon-a11y
// .storybook/main.ts
addons: ['@storybook/addon-a11y']

The addon shows accessibility violations in the Storybook UI. To fail the test-runner on accessibility issues, add this to your test-runner config:

// .storybook/test-runner.ts
import { checkA11y, injectAxe } from 'axe-playwright';

export default {
  async preVisit(page) {
    await injectAxe(page);
  },
  async postVisit(page) {
    await checkA11y(page, '#storybook-root', {
      detailedReport: true,
    });
  },
} satisfies TestRunnerConfig;

This turns accessibility regressions into CI failures — a component that passes visual tests but has a contrast ratio violation will break the build.

The Storybook testing stack — play functions for interactions, the test-runner for CI execution, and either local snapshots or Chromatic for visual diffs — gives you a comprehensive component-level quality gate that integrates with your existing development workflow.

Read more

Start now free