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.
Edge cases and caveats
-
React 18 concurrent mode.
startTransitiondefers state updates to a lower priority lane. If your component wraps a state setter instartTransition,waitFormay time out with its default 1 000 ms threshold. Increase it towaitFor(() => ..., { timeout: 3000 })or useactfromreactto flush the deferred update explicitly. -
Portals and overlay libraries. Menus, dialogs, and tooltips rendered via
ReactDOM.createPortalmount outsidecanvasElement. Scope your query todocument.bodyinstead:within(document.body as HTMLElement).getByRole('dialog'). Storybook 8 exposes thecanvasutility that handles this automatically for most cases. -
Fake timers. If the component uses
setTimeoutinternally and a story opts intoloadersthat 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.
Related
- Interaction Testing — the parent cluster; covers toolchain setup, CI gating, and the full play function lifecycle.
- Storybook & Isolation Workflows — the top-level guide to isolating, documenting, and testing components in Storybook.
- Component Variants — how to create the story variants that play functions run against.
- Argtable Mapping — type-safe arg definitions that keep play function inputs aligned with component prop contracts.
- Essential Storybook Addons for Design System Maintainers — addon setup that supports the interaction testing stack.