Component Testing with Storybook: From Stories to Automated Tests

Storybook started as a UI development environment — a place to build and view components in isolation.

Component Testing with Storybook: From Stories to Automated Tests

Storybook started as a UI development environment — a place to build and view components in isolation. But over the past few years it has grown into a serious component testing platform. With play functions, the @storybook/test package, and the Storybook test-runner, you can write interaction tests directly inside your stories and run them headlessly in CI.

This post covers the full workflow: writing stories with play functions, using testing utilities from @storybook/test, and executing your story-based tests with the test-runner.

Why Test Inside Storybook?

The traditional approach separates concerns: you write stories for visual documentation and unit tests in Jest for behavior. This creates duplication — the same component setup and mock data lives in two places.

Storybook's component testing model collapses that. A story is already a rendered component with specific props and context. Adding a play function turns it into a runnable test. One definition, two purposes.

The other advantage is fidelity. Storybook renders components in a real browser environment (via Playwright under the hood), so you catch browser-specific behavior that jsdom misses.

Setting Up @storybook/test

Install the required packages:

npm install --save-dev @storybook/test @storybook/addon-interactions

Add the interactions addon 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;

@storybook/addon-interactions adds a panel to the Storybook UI where you can step through each interaction in a play function, replay them, and debug failures.

Writing Your First Play Function

A play function runs after the story renders. It receives a canvasElement — the DOM node containing the rendered component — and a set of utilities.

Here is a login form component and its story with interaction testing:

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

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

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

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

    const emailInput = canvas.getByLabelText('Email');
    const passwordInput = canvas.getByLabelText('Password');
    const submitButton = canvas.getByRole('button', { name: 'Sign in' });

    await userEvent.type(emailInput, 'user@example.com');
    await userEvent.type(passwordInput, 'secret123');
    await userEvent.click(submitButton);

    await expect(
      canvas.getByText('Welcome back!')
    ).toBeInTheDocument();
  },
};

within, userEvent, and expect come from @storybook/test, which re-exports Testing Library utilities configured for the Storybook environment. If you have existing Jest tests using @testing-library/react, the API is nearly identical.

Testing Error States

Play functions are useful for verifying error paths that are awkward to set up otherwise:

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

    // Submit without filling in any fields
    await userEvent.click(
      canvas.getByRole('button', { name: 'Sign in' })
    );

    await expect(
      canvas.getByText('Email is required')
    ).toBeInTheDocument();

    await expect(
      canvas.getByText('Password is required')
    ).toBeInTheDocument();
  },
};

export const InvalidCredentials: Story = {
  args: {
    onSubmit: async () => {
      throw new Error('Invalid credentials');
    },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

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

    await expect(
      canvas.getByRole('alert')
    ).toHaveTextContent('Invalid credentials');
  },
};

Each story tests a distinct scenario. The Storybook UI lets you view and debug each one independently, which is a significant developer experience win over running a test file and reading terminal output.

Using step() for Readable Tests

For complex interactions, step() groups actions into labeled phases. This makes failures easier to diagnose:

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

    await step('Fill in personal details', async () => {
      await userEvent.type(canvas.getByLabelText('First name'), 'Jane');
      await userEvent.type(canvas.getByLabelText('Last name'), 'Doe');
    });

    await step('Fill in contact info', async () => {
      await userEvent.type(canvas.getByLabelText('Email'), 'jane@example.com');
      await userEvent.type(canvas.getByLabelText('Phone'), '555-0100');
    });

    await step('Submit and verify', async () => {
      await userEvent.click(canvas.getByRole('button', { name: 'Continue' }));
      await expect(canvas.getByText('Step 2 of 3')).toBeInTheDocument();
    });
  },
};

When a step fails, the error message names the step, so you know immediately where in the flow things went wrong.

Mocking API Calls with MSW

Real components usually fetch data. Use Mock Service Worker to intercept requests inside Storybook:

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

initialize();

export const loaders = [mswLoader];

Then define handlers per story:

import { http, HttpResponse } from 'msw';

export const WithUserData: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/user', () => {
          return HttpResponse.json({ name: 'Jane Doe', role: 'admin' });
        }),
      ],
    },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await expect(canvas.getByText('Jane Doe')).toBeInTheDocument();
    await expect(canvas.getByText('admin')).toBeInTheDocument();
  },
};

MSW intercepts the fetch at the browser network layer, so the component code runs unchanged — it makes a real fetch() call that gets intercepted before leaving the browser.

Running Tests with the Storybook Test-Runner

The test-runner converts every story with a play function into an automated test. Install it:

npm install --save-dev @storybook/test-runner

Add a script to package.json:

{
  "scripts": {
    "test-storybook": "test-storybook"
  }
}

Start Storybook, then in another terminal:

npm run storybook &
npx wait-on http://localhost:6006
npm run test-storybook

The test-runner uses Playwright to open each story in a real browser, execute the play function, and report pass/fail. Output looks like standard Jest output, which makes CI integration straightforward.

For CI, use the --url flag to point at a deployed Storybook rather than starting a local server:

test-storybook --url https://your-storybook.chromatic.com

CI Integration

A minimal GitHub Actions workflow:

name: Storybook Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install chromium --with-deps
      - run: npx concurrently -k -s first -n "SB,TEST"
          "npm run storybook -- --no-open"
          "npx wait-on tcp:6006 && npm run test-storybook"

concurrently starts Storybook and waits for it before running tests. The -k -s first flags kill both processes as soon as the test command exits.

What Component Tests Cover — and What They Don't

Storybook component tests are excellent for:

  • Interaction flows within a single component
  • Form validation logic
  • State transitions (open/closed, loading/loaded, error/success)
  • Accessibility assertions (with the a11y addon)

They do not cover full user journeys that span multiple pages, backend integration, or browser-level behavior like navigation and cookies. For those scenarios, end-to-end testing tools are a better fit. HelpMeTest complements component testing by running full browser tests against your deployed application — covering the flows that start where component tests stop.

Summary

Storybook's component testing capabilities have matured significantly. Play functions let you write interactions directly in stories, @storybook/test provides a familiar Testing Library API, and the test-runner executes everything in a real browser. The result is a testing layer that catches UI regressions early without duplicating setup code between stories and test files.

Read more

Start now free