Storybook Interaction Testing with Play Functions and @storybook/test

Storybook Interaction Testing with Play Functions and @storybook/test

Storybook stories document your components. Play functions make them executable tests. With @storybook/test and play functions, you can write interactions directly in your stories, run them in the Storybook UI with a visual step-by-step debugger, and execute them headlessly in CI.

The result is documentation that is also tests — a story that demonstrates a form submission also verifies the form actually submits correctly.

What Play Functions Are

A play function is an async function attached to a Storybook story that runs after the story renders. It uses @testing-library/user-event and @testing-library/jest-dom assertions through the @storybook/test package.

// src/components/LoginForm/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, args }) => {
    const canvas = within(canvasElement);

    // Fill in the form
    await userEvent.type(
      canvas.getByLabelText('Email'),
      'user@example.com',
      { delay: 50 }
    );

    await userEvent.type(
      canvas.getByLabelText('Password'),
      'correct-password',
      { delay: 50 }
    );

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

    // Assert the onLogin callback was called
    await expect(args.onLogin).toHaveBeenCalledWith({
      email: 'user@example.com',
      password: 'correct-password',
    });
  },
  args: {
    onLogin: fn(),
  },
};

When you view this story in Storybook, the play function runs automatically and you see each step highlighted with a pass/fail indicator in the "Interactions" panel.

Installing @storybook/test

npm install --save-dev @storybook/test

If you're on Storybook 7, you may have @storybook/jest and @storybook/testing-library instead. Storybook 8 consolidates these into @storybook/test. Migrate by replacing:

// Old (Storybook 7)
import { within, userEvent } from '@storybook/testing-library';
import { expect } from '@storybook/jest';

// New (Storybook 8)
import { within, userEvent, expect, fn } from '@storybook/test';

userEvent API

@storybook/test re-exports userEvent from @testing-library/user-event. The most commonly used methods:

// Typing text
await userEvent.type(element, 'text to type');
await userEvent.type(element, 'slow typing', { delay: 50 }); // ms between keystrokes

// Clicking
await userEvent.click(element);
await userEvent.dblClick(element);
await userEvent.click(element, { ctrlKey: true }); // modifier keys

// Keyboard
await userEvent.keyboard('{Enter}');
await userEvent.keyboard('{Tab}');
await userEvent.keyboard('{Escape}');

// Selecting
await userEvent.selectOptions(selectElement, 'Option Value');
await userEvent.deselectOptions(selectElement, 'Option Value');

// Clearing
await userEvent.clear(inputElement);

// Hover
await userEvent.hover(element);
await userEvent.unhover(element);

// Upload
await userEvent.upload(fileInput, file);

The fn() Spy

The fn() function from @storybook/test creates a Vitest-compatible spy (actually wraps @vitest/spy). Use it to track callback calls:

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

export const WithCallbackTracking: Story = {
  args: {
    onSubmit: fn(),
    onCancel: fn(),
    onChange: fn(),
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);

    await userEvent.type(canvas.getByRole('textbox'), 'hello');
    await expect(args.onChange).toHaveBeenCalledTimes(5); // one per character

    await userEvent.click(canvas.getByRole('button', { name: /cancel/i }));
    await expect(args.onCancel).toHaveBeenCalledOnce();
    await expect(args.onSubmit).not.toHaveBeenCalled();
  },
};

Multi-Step Stories

Break complex interactions into steps for better debuggability:

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

export const CheckoutFlow: Story = {
  args: {
    onOrderPlaced: fn(),
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);

    await step('Fill shipping address', async () => {
      await userEvent.type(canvas.getByLabelText('Street'), '123 Main St');
      await userEvent.type(canvas.getByLabelText('City'), 'Springfield');
      await userEvent.selectOptions(canvas.getByLabelText('State'), 'IL');
      await userEvent.type(canvas.getByLabelText('ZIP'), '62701');
    });

    await step('Fill payment details', async () => {
      await userEvent.type(canvas.getByLabelText('Card number'), '4242 4242 4242 4242');
      await userEvent.type(canvas.getByLabelText('Expiry'), '12/26');
      await userEvent.type(canvas.getByLabelText('CVC'), '123');
    });

    await step('Submit order', async () => {
      await userEvent.click(canvas.getByRole('button', { name: /place order/i }));
      await expect(args.onOrderPlaced).toHaveBeenCalledOnce();
    });

    await step('Show confirmation', async () => {
      await expect(canvas.getByText(/order confirmed/i)).toBeInTheDocument();
      await expect(canvas.getByText(/confirmation number/i)).toBeInTheDocument();
    });
  },
};

Steps appear as collapsible sections in the Interactions panel. When a step fails, you see exactly which step and which assertion, not just the line number.

Running Storybook Tests in CI

Install the test runner:

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

Add to package.json:

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

The test runner needs a running Storybook instance:

# Start Storybook, then in another terminal:
npm run test-storybook

Or use concurrently / wait-on for CI:

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

on:
  push:
    branches: [main, 'feature/**']
  pull_request:

jobs:
  storybook-tests:
    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 --with-deps chromium

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

      - name: Run Storybook tests
        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 && npm run test-storybook:ci"

The test runner uses Playwright under the hood. It visits each story, runs the play function, and reports pass/fail. Failures include a screenshot of the failing state.

Configuring the Test Runner

// .storybook/test-runner.js
const { getStoryContext } = require('@storybook/test-runner');

module.exports = {
  async preVisit(page, context) {
    // Run before each story
    await page.setViewportSize({ width: 1280, height: 800 });
  },

  async postVisit(page, context) {
    // Run after each story — useful for accessibility checks
    const storyContext = await getStoryContext(page, context);

    if (storyContext.parameters?.a11y?.disable) return;

    // Run axe accessibility check on every story
    const results = await page.evaluate(async () => {
      const axe = window.axe;
      return await axe.run(document.getElementById('storybook-root'));
    });

    if (results.violations.length > 0) {
      const messages = results.violations
        .map(v => `${v.id}: ${v.description}`)
        .join('\n');
      throw new Error(`Accessibility violations:\n${messages}`);
    }
  },
};

Integrating with Chromatic

Chromatic runs your stories and captures visual snapshots. Combined with play functions, it captures the rendered state after interactions complete.

npm install --save-dev chromatic
npx chromatic --project-token=$CHROMATIC_PROJECT_TOKEN

If a story has a play function, Chromatic runs the play function before taking the snapshot. So your SuccessfulLogin story snapshot shows the form after it's been filled in and submitted — not the initial empty state.

# In your CI workflow:
- name: Publish to Chromatic
  uses: chromaui/action@latest
  with:
    projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
    buildScriptName: build-storybook
    onlyChanged: true  # TurboSnap — only snapshot changed stories

Comparing with Cypress Component Tests

Both Storybook play functions and Cypress component tests run component-level interactions in a real browser. The difference is in philosophy:

Storybook play functions live with your stories. They test the story, not the component in isolation. They're ideal when you want documentation and tests to be the same file.

Cypress component tests live in a separate test file. They give you full Cypress power: time-travel debugging, the Cypress command chain, .should() assertions with automatic retries.

Factor Storybook play Cypress component
Lives with Stories Separate test files
Debugger Interactions panel (step-by-step) Cypress time-travel
Network interception Manual (msw integration) Built-in cy.intercept()
CI runner Playwright (via test-runner) Cypress
Visual regression Chromatic integration Separate setup needed
Assertion retries Limited Built-in (Cypress retries)

For teams already using Storybook, play functions are the zero-friction path to interaction testing — no new tool, no new workflow. If your team is Cypress-first and already has E2E tests, component tests in Cypress may feel more natural.

Many teams use both: Storybook play functions for documenting happy-path interactions, Cypress component tests for edge cases and error states.

Read more

Start now free