Storybook Component Testing: Play Functions, Interaction Tests & CI

Storybook Component Testing: Play Functions, Interaction Tests & CI

Storybook started as a UI development tool — a sandbox where you could render components in isolation and iterate on them without spinning up your whole application. That's still what most people use it for. But since Storybook 7, it has grown into a legitimate component testing platform. You can write interaction tests, run them headlessly in CI, catch visual regressions, and audit accessibility — all from the same story files you're already maintaining.

This guide covers how to actually do that: setting up the test toolchain, writing play functions, running interaction tests, integrating visual regression, and wiring it all into CI. It also covers where Storybook testing ends and where E2E testing needs to take over.

What "Component Testing" Means in Storybook

A Storybook story is a function that renders a component in a specific state. You write one story per meaningful variant: empty state, populated state, error state, disabled state. Traditionally, stories were documentation — you looked at them in the browser.

Component testing in Storybook means attaching a play function to a story. That function runs after the component renders and can interact with it: click buttons, type into inputs, submit forms. After those interactions, it asserts that the UI responded correctly. The story becomes an executable specification.

This is different from unit testing with React Testing Library. It's also different from E2E testing. The component runs in an isolated browser context with no routing, no real API calls unless you set up MSW, and no application shell. That isolation is the point — you're testing the component contract, not the integration.

Setting Up Storybook with @storybook/test and Vitest

Storybook 8+ ships with a native test runner that uses Vitest under the hood. Here's the setup.

First, install Storybook if you haven't already:

npx storybook@latest init

Then add the test dependencies:

npm install --save-dev @storybook/test @storybook/addon-interactions @storybook/experimental-addon-test vitest @vitest/browser

Update your .storybook/main.ts to include the interactions addon:

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

const config: StorybookConfig = {
  stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@storybook/experimental-addon-test',
  ],
  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
};

export default config;

Add a Vitest config that targets your stories:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { storybookTest } from '@storybook/experimental-addon-test/vitest-plugin';

export default defineConfig({
  plugins: [
    storybookTest(),
  ],
  test: {
    browser: {
      enabled: true,
      name: 'chromium',
      provider: 'playwright',
    },
    setupFiles: ['./.storybook/vitest.setup.ts'],
  },
});

Create the setup file:

// .storybook/vitest.setup.ts
import { beforeAll } from 'vitest';
import { setProjectAnnotations } from '@storybook/react';
import * as projectAnnotations from './preview';

const annotations = setProjectAnnotations([projectAnnotations]);

beforeAll(annotations.beforeAll);

Now Vitest can discover and run your stories as tests. Each story with a play function becomes a test case.

Writing Stories as Test Specs with Play Functions

The play function is where the testing happens. It receives a canvasElement — the DOM node containing your rendered component — and a set of utilities from @storybook/test.

Here's a login form component with a real test:

// 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/i });

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

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

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

    const submitButton = canvas.getByRole('button', { name: /sign in/i });
    await userEvent.click(submitButton);

    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'), 'wrongpassword');
    await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));

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

This pattern pays off fast. You're not writing separate test files. The stories you're already maintaining for development become your test suite. Each story variant maps directly to a scenario you care about.

Interaction Testing with userEvent

@storybook/test re-exports everything from @testing-library/user-event and @testing-library/jest-dom. The API is the same as React Testing Library, which means the knowledge transfers.

A few patterns that come up constantly:

Typing and clearing:

const input = canvas.getByRole('textbox', { name: 'Search' });
await userEvent.clear(input);
await userEvent.type(input, 'new query');
await expect(input).toHaveValue('new query');

Selecting from a dropdown:

const select = canvas.getByRole('combobox', { name: 'Status' });
await userEvent.selectOptions(select, 'active');
await expect(select).toHaveValue('active');

Keyboard navigation:

await userEvent.tab(); // focus next element
await userEvent.keyboard('{Enter}');
await userEvent.keyboard('{Escape}');

Waiting for async state:

import { waitFor } from '@storybook/test';

await userEvent.click(canvas.getByRole('button', { name: 'Load data' }));

await waitFor(() => {
  expect(canvas.queryByText('Loading...')).not.toBeInTheDocument();
});

await expect(canvas.getByRole('table')).toBeInTheDocument();

The waitFor utility is essential for components that do async work — data fetching, animations, debounced handlers.

Visual Regression Testing

Interaction tests verify behavior. Visual regression tests verify appearance. Both matter.

Option 1: Chromatic

Chromatic is built by the Storybook team and is the lowest-friction option. It captures pixel-level snapshots of every story and diffs them against a baseline.

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

In CI:

- name: Publish to Chromatic
  uses: chromaui/action@latest
  with:
    projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
    exitZeroOnChanges: true

Chromatic runs in their cloud, so you don't manage browsers or infrastructure. Changes get flagged for human review in their UI. The downside is cost — it's priced per snapshot — and the review workflow requires a separate dashboard.

Option 2: Playwright Screenshots

If you'd rather own the infrastructure, Playwright can take screenshots of individual stories and diff them locally:

// visual.test.ts
import { test, expect } from '@playwright/test';

const stories = [
  { name: 'login-form-default', id: 'forms-loginform--default' },
  { name: 'login-form-error', id: 'forms-loginform--validation-errors' },
  { name: 'button-primary', id: 'components-button--primary' },
];

for (const story of stories) {
  test(`visual: ${story.name}`, async ({ page }) => {
    await page.goto(`http://localhost:6006/iframe.html?id=${story.id}`);
    await page.waitForLoadState('networkidle');
    await expect(page).toHaveScreenshot(`${story.name}.png`, {
      threshold: 0.02,
    });
  });
}

Run with --update-snapshots to establish the baseline. Subsequent runs fail on visual changes exceeding the threshold. Commit the snapshots to git.

The tradeoff: you manage the browser, the baseline images, and the diff workflow. More control, more maintenance.

Accessibility Testing with the a11y Addon

The @storybook/addon-a11y addon runs axe-core against every rendered story and reports violations in the Storybook UI.

npm install --save-dev @storybook/addon-a11y

Add it to your main config:

addons: [
  '@storybook/addon-essentials',
  '@storybook/addon-interactions',
  '@storybook/addon-a11y',
],

Now every story gets an "Accessibility" panel showing violations, incomplete checks, and passes. This catches common issues automatically: missing labels, insufficient color contrast, incorrect ARIA roles, missing alt text.

You can configure per-story:

export const WithA11yConfig: Story = {
  parameters: {
    a11y: {
      config: {
        rules: [
          {
            id: 'color-contrast',
            enabled: false, // disable a rule you're intentionally violating
          },
        ],
      },
    },
  },
};

To make a11y violations fail tests in CI, use the axe-playwright or jest-axe approach in your play functions:

import { checkA11y } from 'axe-playwright';

export const AccessibleForm: Story = {
  play: async ({ canvasElement }) => {
    // your interaction tests...

    // then check a11y
    await checkA11y(canvasElement, {
      runOnly: {
        type: 'tag',
        values: ['wcag2a', 'wcag2aa'],
      },
    });
  },
};

The value here is catching accessibility regressions at the component level — before they make it into the application.

Running Storybook Tests in CI

With the Vitest setup above, running story tests in CI is a standard Vitest run:

npx vitest run

No Storybook server needed. The @storybook/experimental-addon-test plugin handles rendering components in Playwright's browser context directly.

Here's a complete GitHub Actions workflow:

name: Component Tests

on:
  push:
    branches: [main]
  pull_request:

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

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

      - run: npm ci

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

      - name: Run Storybook component tests
        run: npx vitest run

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: test-results
          path: test-results/

For visual regression with Playwright snapshots, add snapshot storage:

      - name: Run visual tests
        run: npx playwright test visual.test.ts

      - name: Upload diff artifacts on failure
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: visual-diffs
          path: test-results/

Keep your interaction tests and visual tests as separate jobs if visual tests are slow. Interaction tests should be fast — under two minutes for most component libraries.

Storybook vs React Testing Library — When to Use Each

Both use the same underlying tools (@testing-library APIs, userEvent). The choice is about what you're testing.

Use Storybook tests when:

  • You want living documentation that's also a test suite
  • You're testing component variants extensively (a button library with 20 variants)
  • You need visual regression testing as part of the workflow
  • The component has complex internal state that's hard to set up in a pure test file
  • You're doing accessibility audits at the component level

Use React Testing Library when:

  • You're testing components that don't have Storybook stories and won't benefit from documentation
  • You need to mock modules at the file level (RTL works better with Jest/Vitest module mocking)
  • You're testing hooks in isolation with renderHook
  • The test setup involves complex providers that are easier to configure outside Storybook's decorator system
  • Your team doesn't maintain Storybook stories (don't add the overhead of Storybook just to get RTL)

In practice, most React projects benefit from both. Storybook tests cover the component library and design system components where visual accuracy matters. RTL covers application-specific components where behavior and integration with hooks matter more than visual states.

The mistake is treating them as alternatives. They're complements.

Combining Storybook Tests with E2E Testing

Storybook tests and E2E tests cover different failure modes.

Storybook tests verify that a LoginForm component, given a valid email and password, calls onSubmit with the right arguments and shows a success message. It doesn't verify that the login endpoint exists, that the JWT gets stored correctly, that the redirect happens, or that the authenticated session persists across a page reload.

E2E tests cover the full stack. For a login flow, that means: navigate to /login, enter credentials, submit, get redirected to /dashboard, reload the page, confirm you're still authenticated.

The coverage model is a pyramid:

  • Storybook tests: many, fast, component-level, isolated
  • Integration tests (RTL with MSW): medium, component + data layer
  • E2E tests: fewer, slow, full stack, real browser

The trap is writing E2E tests for things Storybook already covers. If your Storybook tests already verify that the login form shows a validation error when the email field is empty, your E2E test doesn't need to test that. The E2E test should start from a valid form submission and verify what happens after the component hands off to the rest of the system.

Tools like HelpMeTest handle the E2E layer with Robot Framework and Playwright, letting you write tests in plain language without maintaining browser automation code yourself. You describe the scenario — "User logs in with valid credentials and lands on the dashboard" — and HelpMeTest generates and runs the test, monitors it on a schedule, and alerts you when it breaks. The AI-powered visual testing catches layout regressions that neither Storybook snapshots nor interaction tests would catch.

A realistic coverage split for a React application:

  • Storybook tests: every component in the design system, all form variants, all error states, interaction tests for complex widgets (date pickers, autocomplete, rich text editors)
  • React Testing Library: application components that are too tightly coupled to routing or context to isolate cleanly, custom hooks
  • HelpMeTest / E2E: critical user journeys — signup, login, checkout, any flow where a real user would lose real data if something broke

What This Gets You

A fully tested component library means your design system is a contract. When you update a component, the play functions tell you immediately what broke. The visual regression baseline tells you what changed visually. The a11y checks tell you what regressed for screen reader users.

That's the foundation. The E2E tests on top of it tell you whether the application that uses those components still works end-to-end.

Neither layer alone is enough. Both together give you the confidence to ship.


HelpMeTest handles the E2E layer without the overhead of maintaining a Playwright test suite yourself. Write tests in plain English, run them on a 24/7 schedule, get AI-powered visual diff alerts when something breaks. Free plan includes 10 tests with continuous monitoring. Pro plan is $100/month with unlimited tests.

Start testing at helpmetest.com

Read more