Storybook Interaction Testing with @storybook/test

Storybook Interaction Testing with @storybook/test

Storybook interaction testing lets you simulate user behavior directly inside your stories — clicks, typing, keyboard navigation — and assert the results. Since Storybook 8, all of this runs through the @storybook/test package, which provides a unified API built on top of Testing Library and Vitest.

This guide covers everything you need to write, debug, and run interaction tests in Storybook.

The @storybook/test Package

Before Storybook 8, interaction testing required two packages: @storybook/testing-library for user events and @storybook/jest for assertions. Now there's one:

npm install --save-dev @storybook/test

It exports everything you need:

import { expect, userEvent, within, fn, waitFor } from '@storybook/test';
  • userEvent — simulates realistic browser events (type, click, keyboard, pointer)
  • within — scoped DOM queries, same API as @testing-library/dom
  • expect — assertion library compatible with Vitest/Jest matchers
  • fn — creates spy functions for tracking calls
  • waitFor — polls until an assertion passes

The play Function

A play function is an async function attached to a story that runs after the component renders. This is where your interaction test lives.

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

const meta: Meta<typeof LoginForm> = {
  component: LoginForm,
};
export default meta;

type Story = StoryObj<typeof LoginForm>;

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

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

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

The play function receives a context object with:

  • canvasElement — the root DOM node of the rendered story
  • args — the story's current args (including arg controls from the Storybook UI)
  • step — for grouping assertions into named groups
  • context — the full story context object

Querying DOM Elements

Use within(canvasElement) to get scoped query functions. This prevents false positives from elements outside your component.

const canvas = within(canvasElement);

// By accessible role (preferred)
canvas.getByRole('button', { name: 'Submit' });
canvas.getByRole('textbox', { name: 'Email' });
canvas.getByRole('checkbox', { name: 'Remember me' });

// By label (good for forms)
canvas.getByLabelText('Password');

// By text content
canvas.getByText('Success!');

// By test id (last resort)
canvas.getByTestId('error-message');

Always prefer getBy* over queryBy* in play functions — if the element isn't there, you want a clear failure, not a null.

Simulating User Events

userEvent provides realistic event simulation. Unlike fireEvent, it dispatches the full sequence of browser events that a real user action would trigger.

// Typing
await userEvent.type(canvas.getByLabelText('Search'), 'react testing');

// Clearing and retyping
await userEvent.clear(canvas.getByLabelText('Email'));
await userEvent.type(canvas.getByLabelText('Email'), 'new@example.com');

// Clicking
await userEvent.click(canvas.getByRole('button', { name: 'Delete' }));

// Double click
await userEvent.dblClick(canvas.getByText('Edit'));

// Keyboard shortcuts
await userEvent.keyboard('{Enter}');
await userEvent.keyboard('{Escape}');
await userEvent.keyboard('{Control>}z{/Control}'); // Ctrl+Z

// Select from dropdown
await userEvent.selectOptions(
  canvas.getByRole('combobox'),
  canvas.getByRole('option', { name: 'Option B' })
);

// Tab navigation
await userEvent.tab();
await userEvent.tab({ shift: true }); // Shift+Tab

Always await user events — they're asynchronous.

Writing Assertions

Use expect for assertions. It supports all standard matchers plus DOM-specific matchers from @testing-library/jest-dom:

// Presence
await expect(canvas.getByText('Error!')).toBeInTheDocument();
await expect(canvas.queryByText('Error!')).not.toBeInTheDocument();

// Visibility
await expect(canvas.getByRole('dialog')).toBeVisible();
await expect(canvas.getByRole('tooltip')).not.toBeVisible();

// State
await expect(canvas.getByRole('button', { name: 'Submit' })).toBeDisabled();
await expect(canvas.getByRole('checkbox')).toBeChecked();

// Value
await expect(canvas.getByRole('textbox')).toHaveValue('hello');

// Class
await expect(canvas.getByRole('alert')).toHaveClass('error');

// Attribute
await expect(canvas.getByRole('link')).toHaveAttribute('href', '/home');

Waiting for Async Updates

When your component makes API calls or has delayed state updates, use waitFor:

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

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

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

    // Wait for loading state to appear and then resolve
    await waitFor(() =>
      expect(canvas.queryByText('Loading...')).not.toBeInTheDocument()
    );

    await expect(canvas.getByText('Data loaded')).toBeInTheDocument();
  },
};

waitFor retries the assertion every 50ms until it passes or times out (default 1000ms). Increase the timeout for slow operations:

await waitFor(
  () => expect(canvas.getByText('Saved')).toBeInTheDocument(),
  { timeout: 3000 }
);

Spying on Args and Callbacks

Use fn() to create spy functions and track calls. Combine with args:

const meta: Meta<typeof Button> = {
  component: Button,
  args: {
    onClick: fn(), // Spy on click handler
  },
};

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

    await userEvent.click(canvas.getByRole('button'));

    // Assert the callback was called
    await expect(args.onClick).toHaveBeenCalledOnce();
    await expect(args.onClick).toHaveBeenCalledWith(
      expect.objectContaining({ type: 'click' })
    );
  },
};

This pattern is essential for testing components that notify parents via callbacks — modal close handlers, form submit callbacks, selection changes.

Grouping with step

The step function groups interactions into named sections. Steps appear as collapsible items in the Interactions panel, making complex tests much easier to debug.

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

    await step('Enter personal information', async () => {
      await userEvent.type(canvas.getByLabelText('First name'), 'Jane');
      await userEvent.type(canvas.getByLabelText('Last name'), 'Smith');
    });

    await step('Select preferences', async () => {
      await userEvent.click(canvas.getByLabelText('Newsletter'));
      await userEvent.selectOptions(
        canvas.getByLabelText('Frequency'),
        'weekly'
      );
    });

    await step('Submit and verify', async () => {
      await userEvent.click(canvas.getByRole('button', { name: 'Save' }));
      await expect(canvas.getByText('Preferences saved')).toBeVisible();
    });
  },
};

Debugging in the Interactions Panel

When a play function fails, the Interactions panel shows:

  1. The step that failed (highlighted in red)
  2. The exact assertion that didn't pass
  3. A replay button to re-run from any step
  4. Time-travel through each interaction

Click the replay button on a step to re-run from that point. Click the step marker to jump to that point in the interaction timeline. This makes debugging interaction tests significantly faster than reading stack traces.

Reusing play Functions

You can compose play functions across stories:

export const Filled: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com');
    await userEvent.type(canvas.getByLabelText('Password'), 'password');
  },
};

export const SubmittedWithValidData: Story = {
  play: async (context) => {
    // Reuse Filled's interactions
    await Filled.play!(context);

    // Then submit
    const canvas = within(context.canvasElement);
    await userEvent.click(canvas.getByRole('button', { name: 'Sign in' }));
    await expect(canvas.getByText('Welcome!')).toBeInTheDocument();
  },
};

This keeps tests DRY without sacrificing readability.

Running Interaction Tests in CI

Use the Storybook test runner to run all interaction tests in CI:

npm install --save-dev @storybook/test-runner
# .github/workflows/test.yml
- name: Build Storybook
  run: npm run build-storybook

- name: Run interaction tests
  run: |
    npx concurrently -k -s first \
      "npx http-server storybook-static -p 6006 --silent" \
      "npx wait-on tcp:6006 && npx test-storybook --url http://localhost:6006"

Failed play functions show up as test failures with the full assertion error and step path.

Common Patterns

Testing error states:

export const ValidationError: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    // Submit without filling required fields
    await userEvent.click(canvas.getByRole('button', { name: 'Submit' }));
    await expect(canvas.getByRole('alert')).toHaveTextContent('Email is required');
  },
};

Testing keyboard accessibility:

export const KeyboardNavigation: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const firstItem = canvas.getByRole('menuitem', { name: 'Home' });
    firstItem.focus();
    await userEvent.keyboard('{ArrowDown}');
    await expect(canvas.getByRole('menuitem', { name: 'About' })).toHaveFocus();
  },
};

Testing modal open/close:

export const OpenAndClose: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByRole('button', { name: 'Open dialog' }));
    await expect(canvas.getByRole('dialog')).toBeVisible();
    await userEvent.keyboard('{Escape}');
    await expect(canvas.queryByRole('dialog')).not.toBeInTheDocument();
  },
};

Storybook interaction testing covers the component behavior layer that unit tests miss and E2E tests make expensive. Once a play function exists, it's documentation, a test, and a development tool simultaneously.

Read more