Writing interaction tests with Storybook play function
The play function operates as an automated user simulation layer executed directly within isolated component environments. Unlike static visual regression, which captures pixel-level snapshots, interaction tests validate deterministic state transitions, DOM mutations, and accessibility compliance. This establishes a rigorous testing baseline within the broader Storybook & Isolation Workflows methodology, ensuring component contracts remain intact across framework upgrades and design system iterations.
To enable execution, install and register the required addon:
npm i -D @storybook/addon-interactions @storybook/test
// Basic play function signature
export const Primary = {
play: async ({ canvasElement }) => {
// Interaction logic executes here
},
};
Symptom Identification: Recognizing Failing Play Functions
Failing play functions typically manifest as non-deterministic timeouts or silent assertion failures. Diagnose using these observable patterns:
- Test Timeouts/Hangs: Caused by unhandled promises or missing
waitForboundaries that leave the event loop in a pending state. - False Negatives:
findBy*queries fail because the framework hasn’t flushed microtasks or batched state updates before the assertion runs. - CI Flakiness: Headless browsers in pipeline environments render slower than local dev servers, causing race conditions that don’t reproduce locally.
- Bypassed Event Queues: Direct DOM manipulation (
element.click()) skips the synthetic event pipeline, triggering inaccurate state assertions.
Diagnostic CLI Flags:
# Increase timeout threshold and output verbose execution traces
npx storybook test --test-timeout=30000 --verbose
Debugging Pattern: Log the canvas DOM state immediately before and after interactions to isolate mutation boundaries:
export const DebugStory = {
play: async ({ canvasElement }) => {
console.log('Pre-interaction DOM:', canvasElement.innerHTML);
await userEvent.click(within(canvasElement).getByRole('button'));
console.log('Post-interaction DOM:', canvasElement.innerHTML);
},
};
Root Cause Analysis: Debugging Async & Event Conflicts
Interaction failures rarely stem from incorrect assertions; they originate from execution thread misalignment. Common architectural conflicts include:
- Framework Re-render Race Conditions: Test threads execute faster than React/Vue/Svelte reconciliation cycles, causing queries to run against stale DOM trees.
- Synthetic Event Misconfiguration: Default
userEventsetups may bypass native listeners when framework-specific event delegation is active. - Disabled Addon Processing: Missing
preview.jsparameters can silently disable the interaction runner, causing tests to skip execution without throwing errors. - Third-Party Event Interception: Portals, dropdown wrappers, or focus traps consume pointer/focus events before the play function can assert state.
For deeper architectural context on execution pipelines, consult the Interaction Testing cluster documentation.
Deterministic Async Guard:
import { waitFor, within, expect } from '@storybook/test';
// Wait for DOM mutation before asserting
export const WithSuccess = {
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
await waitFor(() =>
expect(within(canvasElement).getByText('Success')).toBeVisible()
);
},
};
Correct Event Setup with Timer Advancement:
import { userEvent } from '@storybook/test';
// Align synthetic events with framework timer queues using userEvent.setup()
const user = userEvent.setup();
// Use `user.click(element)` rather than the static `userEvent.click(element)`
// when you need fine-grained control over pointer position or modifiers.
Reproducible Fixes: Implementation Patterns & Config
Standardize interaction tests using a strict setup → query → interact → assert lifecycle. This eliminates cross-component DOM leakage and ensures deterministic execution order.
Core Implementation Pattern:
import { userEvent, within, waitFor, expect } from '@storybook/test';
export const WithInteraction = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole('button', { name: /open menu/i });
// 1. Interact
await userEvent.click(trigger);
// 2. Wait for async state transition and assert
await waitFor(() => {
const menu = canvas.getByRole('menu');
expect(menu).toHaveAttribute('aria-expanded', 'true');
});
},
};
Enable Debug Mode in preview.ts:
Force the interaction runner to log step-by-step execution traces for failing stories:
// .storybook/preview.ts
import type { Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
interactions: {
disable: false,
},
},
};
export default preview;
Focus & Keyboard Navigation Handling:
import { userEvent } from '@storybook/test';
await userEvent.tab(); // Move focus to next interactive element
await userEvent.keyboard('{Enter}'); // Trigger keyboard activation
CI Prevention: Pipeline Guardrails & Automation
Isolated interaction tests must be integrated into pre-merge validation to prevent regression drift. Configure headless execution, parallelization, and strict failure thresholds.
GitHub Actions / GitLab CI Snippet:
- name: Run Storybook Interaction Tests
run: npx storybook test --ci --url http://localhost:6006
env:
CI: true
NODE_ENV: test
Test Runner Configuration (test-runner.config.ts):
import type { TestRunnerConfig } from '@storybook/test-runner';
const config: TestRunnerConfig = {
async postVisit(page, context) {
// Custom assertions after each story renders
},
};
export default config;
Pipeline Enforcement Strategy:
- Set viewport dimensions explicitly in
playwright.config.tsto eliminate responsive rendering discrepancies. - Implement PR blocking rules that fail merges when play function coverage drops below 85%.
- Cache
node_modulesand Storybook static assets to reduce CI cold-start latency.