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:
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 } from '@storybook/test';
// Wait for DOM mutation before asserting
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
const user = userEvent.setup({
advanceTimers: jest.advanceTimersByTime,
});
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 } from '@storybook/testing-library';
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
await userEvent.waitFor(() => {
// 3. Assert deterministic DOM/ARIA state
const menu = canvas.getByRole('menu');
expect(menu).toHaveAttribute('aria-expanded', 'true');
});
},
};
Enable Debug Mode in preview.js:
Force the interaction runner to log step-by-step execution traces for failing stories:
// .storybook/preview.js
export const parameters = {
interactions: {
debug: true, // Outputs step execution to Storybook UI & console
clearMocks: true,
},
};
Focus & Keyboard Navigation Handling:
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
env:
CI: true
NODE_ENV: test
# Map mock API endpoints for isolated execution
API_BASE_URL: http://localhost:6006/__mocks__
Test Runner Configuration (test-runner.config.js):
module.exports = {
browsers: ['chromium'],
timeout: 30000,
// Enable retry logic for environment-specific flakiness
retries: 2,
// Shard execution across parallel runners
parallel: true,
};
Pipeline Enforcement Strategy:
- Set viewport dimensions explicitly via
--viewport=1280x720to 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 by ~40%.