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 waitFor boundaries 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 userEvent setups may bypass native listeners when framework-specific event delegation is active.
  • Disabled Addon Processing: Missing preview.js parameters 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=1280x720 to eliminate responsive rendering discrepancies.
  • Implement PR blocking rules that fail merges when play function coverage drops below 85%.
  • Cache node_modules and Storybook static assets to reduce CI cold-start latency by ~40%.