Writing Interaction Tests with Storybook play()

Your component renders correctly in isolation — but does it behave correctly when a user clicks, types, or tabs through it? That gap is exactly what the play function closes. It runs automated user-event sequences inside the same sandboxed iframe that Storybook uses for story rendering, so every assertion fires against real browser DOM rather than a jsdom approximation.

This page is a focused how-to under the Interaction Testing cluster, which itself sits inside the wider Storybook & Isolation Workflows methodology.


Problem statement

You have a play function that simulates a button click and asserts a loading state, but assertions fail intermittently with Unable to find element or time out silently in CI. The tests pass on your laptop every time.


Root cause explanation

The play function executes in the same JavaScript event loop as the rendered component. When a framework like React or Vue batches state updates, the DOM mutation the assertion depends on may not have flushed by the time the next line runs. Headless Chromium in CI processes microtasks at a different cadence than a local dev server under Vite HMR, turning a race condition that is invisible locally into a consistent failure in the pipeline.

The deeper issue is that the Interaction Testing infrastructure relies on @testing-library-style async queries — functions like findBy* and waitFor — to let the test thread yield until the DOM reaches the expected state. Skipping those guards is the single most common root cause of flaky play functions.


Minimal reproduction

The snippet below triggers the problem: getByText runs synchronously before React has committed the 'Saved' text to the DOM.

// ❌ Synchronous assertion races against React state flush
export const Broken: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByRole('button', { name: /save/i }));
    // Fails if React hasn't flushed the state update yet
    expect(canvas.getByText('Saved')).toBeInTheDocument();
  },
};

Step-by-step fix

Step 1 — Install and register the required packages

# Install the interaction addon and unified test utilities
npm install -D @storybook/addon-interactions @storybook/test

Register the addon in .storybook/main.ts so the Interactions panel appears and the test runner can pick up play functions:

// .storybook/main.ts — add addon-interactions to the addons array
import type { StorybookConfig } from '@storybook/react-vite';

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

export default config;

Step 2 — Wrap async assertions in waitFor

Replace the bare getByText with a waitFor block. The callback is retried on a 50 ms interval until it passes or a timeout fires.

// ✅ Correct: waitFor retries until React flushes the state update
import { userEvent, within, waitFor, expect } from '@storybook/test';
import type { Meta, StoryObj } from '@storybook/react';
import { SaveButton } from './SaveButton';

const meta: Meta<typeof SaveButton> = { component: SaveButton };
export default meta;
type Story = StoryObj<typeof SaveButton>;

export const SavesSuccessfully: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    // 1. Interact — userEvent dispatches a realistic pointer+click sequence
    await userEvent.click(canvas.getByRole('button', { name: /save/i }));
    // 2. Assert — waitFor yields until 'Saved' is visible or timeout fires
    await waitFor(() =>
      expect(canvas.getByText('Saved')).toBeVisible()
    );
  },
};

Step 3 — Use userEvent.setup() for stateful interaction sequences

For keyboard navigation, focus traps, or multi-step flows where modifier state carries across events, create a userEvent instance with setup(). This keeps pointer state and timer configuration consistent across every step.

// userEvent.setup() preserves modifier keys and fake timer config across steps
export const KeyboardNavigation: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const user = userEvent.setup(); // instance-based API

    // Tab to the first interactive element
    await user.tab();
    expect(canvas.getByRole('textbox', { name: /email/i })).toHaveFocus();

    // Type a value, then submit with Enter
    await user.keyboard('[email protected]');
    await user.keyboard('{Enter}');

    await waitFor(() =>
      expect(canvas.getByRole('alert')).toHaveTextContent(/welcome/i)
    );
  },
};

Step 4 — Compose play functions for multi-step flows

Import a sibling story and call its play property to reuse interaction logic without duplication:

// Compose: run the Login story's interaction, then assert the dashboard loads
import { LoginForm } from './LoginForm.stories';

export const LoggedInDashboard: Story = {
  play: async (context) => {
    // Reuse the existing login interaction as a setup step
    await LoginForm.play!(context);
    // Then assert the downstream state
    await waitFor(() =>
      expect(within(context.canvasElement).getByRole('heading', {
        name: /dashboard/i,
      })).toBeVisible()
    );
  },
};

Verification

Run the test runner against your built Storybook and look for a clean pass:

# Build Storybook first, then run all play functions headlessly
npx storybook build --quiet
npx storybook test --url http://localhost:6006 --ci

Expected output when the fix is working:

 PASS  src/components/SaveButton.stories.ts
  ✓ SavesSuccessfully (312 ms)
  ✓ KeyboardNavigation (489 ms)
  ✓ LoggedInDashboard (601 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total

If a test is still flaky, add --test-timeout=30000 to rule out infrastructure latency before investigating selector stability.


Execution flow diagram

The diagram below shows how the Storybook test runner coordinates the browser, the play function, and assertions. Understanding which step can stall helps you place waitFor boundaries in the right location.

Storybook play() execution flow Sequence diagram showing: test runner loads story iframe, component renders, play function begins, userEvent dispatches synthetic events, framework reconciles state, waitFor polls DOM, assertion passes or retries. Test Runner play() function Browser / Component Load story iframe Component mounts + renders Invoke play(context) userEvent dispatch Framework reconciles state waitFor polls DOM (every 50 ms) DOM mutation committed expect() passes ✓

Edge cases and caveats

  • React 18 concurrent mode. startTransition defers state updates to a lower priority lane. If your component wraps a state setter in startTransition, waitFor may time out with its default 1 000 ms threshold. Increase it to waitFor(() => ..., { timeout: 3000 }) or use act from react to flush the deferred update explicitly.

  • Portals and overlay libraries. Menus, dialogs, and tooltips rendered via ReactDOM.createPortal mount outside canvasElement. Scope your query to document.body instead: within(document.body as HTMLElement).getByRole('dialog'). Storybook 8 exposes the canvas utility that handles this automatically for most cases.

  • Fake timers. If the component uses setTimeout internally and a story opts into loaders that install fake timers, userEvent.setup() must be called with { advanceTimers: vi.advanceTimersByTime } (or the Jest equivalent) so pointer delays advance the fake clock rather than stalling the real one.


FAQ

Why does findBy* sometimes return stale elements after a click?

findBy* queries return a promise that resolves to the first match found after re-polling. If the click triggers a full unmount and remount cycle (e.g. a route change inside an isolated component), the element reference becomes stale. Re-query inside the waitFor callback instead of hoisting the findBy* call: await waitFor(() => expect(canvas.getByRole('status')).toBeVisible()).

Can I step through a play function interactively?

Yes. With @storybook/addon-interactions installed and the story open in the browser, the Interactions panel shows each step. Click the rewind button to replay from any step, or pause execution mid-sequence. This is far faster than adding console.log statements for debugging async race conditions.

How do I test that a component does NOT change state after an interaction?

Use expect(...).not.toBeInTheDocument() inside a waitFor block with a short timeout, or assert immediately after the interaction without a guard — the absence check only needs to confirm the DOM state at that moment, not wait for a future change.