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:

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 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, 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.ts 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.