What is Interaction Testing?
Interaction testing verifies that your UI components behave correctly when users interact with them. Unlike visual tests that capture screenshots, interaction tests simulate real user behavior--clicks, typing, keyboard navigation, and more--to ensure components respond accurately to every interaction.
Modern web applications consist of increasingly complex components with sophisticated interactive behavior. A button might need to handle loading states, disabled conditions, and various click scenarios. A form component must manage input validation, error displays, and submission workflows. Interaction tests ensure these behaviors work correctly without requiring full application integration.
Traditional testing approaches have limitations. Unit tests focus on individual functions but miss UI behavior. End-to-end tests cover complete user flows but are slow and brittle. Interaction tests occupy a sweet spot--testing component behavior in isolation while simulating realistic user interactions.
For teams building React applications, component libraries are foundational. Interaction testing these components ensures consistency across your entire application while providing living documentation that evolves with your codebase.
Why Interaction Testing Matters
Storybook's interaction testing system combines several powerful tools to validate component functionality:
- Play Functions: Define test scenarios directly within your story definitions
- Testing Library: Simulate user events and make assertions about component state
- Test Runner: Execute tests in parallel across browsers automatically
- Interactions Panel: Debug tests step by step to understand exactly what happened
Interaction tests catch bugs that visual tests miss--a button that looks correct but doesn't respond to clicks, a dropdown that opens but doesn't close on outside click, or a form that accepts invalid input. These functional issues directly impact user experience but are invisible to screenshot-based testing.
Setting Up Your Testing Environment
Before writing interaction tests, ensure your project has the necessary dependencies installed. Most modern Storybook installations include interaction testing support by default, but verifying your setup ensures everything works correctly.
1# Install Testing Library packages2npm install --save-dev @testing-library/user-event @testing-library/dom3 4# Install Storybook test runner (if not included)5npm install --save-dev @storybook/test-runner6 7# Verify @storybook/test package is available8npm list @storybook/testYour .storybook/main.ts file should already include Storybook's testing module. The addon-interactions provides the Interactions panel in Storybook's UI, which is essential for debugging tests and understanding interaction sequences:
1import type { StorybookConfig } from '@storybook/react-vite';2 3const config: StorybookConfig = {4 stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],5 addons: [6 '@storybook/addon-links',7 '@storybook/addon-essentials',8 '@storybook/addon-interactions',9 ],10 framework: {11 name: '@storybook/react-vite',12 options: {},13 },14 docs: {15 autodocs: 'tag',16 },17};18 19export default config;Writing Play Functions
Play functions are the core of Storybook interaction testing. They define a sequence of interactions and assertions that run automatically when a story loads. A play function is essentially a test scenario written in code that your team can trust and maintain.
1import type { Meta, StoryObj } from '@storybook/react';2import { fn } from '@storybook/test';3import { LoginForm } from './LoginForm';4 5const meta: Meta<typeof LoginForm> = {6 title: 'Components/LoginForm',7 component: LoginForm,8 args: {9 onSubmit: fn(),10 },11};12 13export default meta;14type Story = StoryObj<typeof meta>;15 16export const Filled: Story = {17 play: async ({ canvasElement }) => {18 const canvas = within(canvasElement);19 20 // Type email21 await userEvent.type(canvas.getByLabelText('Email'), '[email protected]');22 23 // Type password24 await userEvent.type(canvas.getByLabelText('Password'), 'password123');25 26 // Submit form27 await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));28 },29};The play function receives a context object containing useful utilities:
- canvasElement: The DOM element where the component renders
- canvas: A Testing Library wrapper scoped to the canvas for querying
- step: Log named steps for better debugging and test organization
- userEvent: Simulate user interactions like clicks, typing, and keyboard input
- expect: Make assertions about component state and behavior
1export const WithValidationErrors: Story = {2 play: async ({ canvasElement, step }) => {3 const canvas = within(canvasElement);4 5 await step('Empty form submission', async () => {6 await userEvent.click(canvas.getByRole('button', { name: /submit/i }));7 8 // Assert error messages appear9 await expect(canvas.getByText('Email is required')).toBeInTheDocument();10 await expect(canvas.getByText('Password must be at least 8 characters')).toBeInTheDocument();11 });12 13 await step('Fill with valid data', async () => {14 await userEvent.clear(canvas.getByLabelText('Email'));15 await userEvent.type(canvas.getByLabelText('Email'), '[email protected]');16 17 await userEvent.clear(canvas.getByLabelText('Password'));18 await userEvent.type(canvas.getByLabelText('Password'), 'securepassword123');19 20 await userEvent.click(canvas.getByRole('button', { name: /submit/i }));21 22 // Assert success state23 await expect(canvas.getByText('Success!')).toBeInTheDocument();24 });25 },26};Testing Library Integration
Storybook integrates seamlessly with Testing Library, providing familiar APIs for user event simulation and assertions. This integration means you can use the same testing patterns across Storybook, unit tests, and end-to-end tests, reducing context switching for your development team.
For teams implementing custom web solutions, consistent testing patterns across your component library reduce maintenance overhead and improve developer productivity. The same Testing Library approach works whether you're testing isolated components in Storybook or full integration tests.
| Method | Description | Example |
|---|---|---|
| userEvent.click() | Simulate mouse click | userEvent.click(button) |
| userEvent.dblClick() | Simulate double click | userEvent.dblClick(item) |
| userEvent.type() | Type text into input | userEvent.type(input, 'text') |
| userEvent.clear() | Clear input value | userEvent.clear(input) |
| userEvent.keyboard() | Press keyboard keys | userEvent.keyboard('{Enter}') |
| userEvent.hover() | Hover over element | userEvent.hover(button) |
| userEvent.upload() | Upload files | userEvent.upload(input, file) |
1import { userEvent, within, expect } from '@storybook/test';2import { SearchDropdown } from './SearchDropdown';3 4export const WithResults: Story = {5 play: async ({ canvasElement }) => {6 const canvas = within(canvasElement);7 const input = canvas.getByRole('combobox', { name: /search/i });8 9 // Type in search box10 await userEvent.type(input, 'react');11 12 // Wait for dropdown to appear13 const dropdown = await canvas.findByRole('listbox');14 15 // Verify results appear16 await expect(within(dropdown).getByText('React')).toBeInTheDocument();17 await expect(within(dropdown).getByText('React Native')).toBeInTheDocument();18 19 // Select first result20 await userEvent.click(within(dropdown).getByText('React'));21 22 // Verify selection23 await expect(input).toHaveValue('React');24 },25};The within() function creates a scoped query function limited to a specific container. This is essential when testing nested components or components that render into portals, ensuring your queries don't accidentally match elements outside the component being tested.
Query Priority and Best Practices
Testing Library's guiding principle is to query elements the same way users find them. This means prioritizing semantic, accessible queries over arbitrary attributes or implementation details. Following this principle produces tests that remain stable even when implementation details change.
| Priority | Query Type | When to Use |
|---|---|---|
| 1 | ByRole (with name) | Most elements, especially interactive ones |
| 2 | ByLabelText | Form inputs with visible labels |
| 3 | ByPlaceholderText | Inputs without labels but with placeholder |
| 4 | ByText | Non-interactive text content |
| 5 | ByTestId | Last resort, for dynamic content |
1// PREFERRED - Accessible, semantic queries2expect(canvas.getByRole('button', { name: /submit/i })).toBeEnabled();3expect(canvas.getByRole('checkbox', { name: /agree to terms/i })).toBeChecked();4expect(canvas.getByRole('combobox', { name: /country/i })).toHaveValue('CA');5 6// AVOID - Implementation-specific queries7expect(canvas.getByTestId('submit-button')).toBeEnabled();8expect(container.querySelector('.btn-primary')).toBeInTheDocument();9expect(container.querySelector('[data-qa="checkbox"]')).toBeChecked();Testing Library provides three query variants with different behaviors:
- getBy*: Synchronous, throws if not found (use for elements that should exist immediately)
- queryBy*: Synchronous, returns null if not found (use for checking absence of elements)
- findBy*: Asynchronous, waits for element to appear (use for elements that appear after async operations)
1export const LoadingStates: Story = {2 play: async ({ canvasElement }) => {3 const canvas = within(canvasElement);4 5 // Initial state - spinner should be visible6 expect(canvas.getByRole('status')).toHaveTextContent('Loading...');7 8 // Wait for loading to complete (findBy is async)9 const content = await canvas.findByRole('article');10 11 // Verify spinner is gone (queryBy returns null, not throws)12 expect(canvas.queryByRole('status')).not.toBeInTheDocument();13 14 // Verify content loaded15 expect(content).toHaveTextContent('Data loaded successfully');16 },17};Automating with Test Runner
The Storybook Test Runner executes all your play functions automatically, enabling interaction testing at scale. It runs stories as test files, making interaction testing integrate naturally with your existing test infrastructure and CI/CD pipeline.
1{2 "scripts": {3 "test-storybook": "test-storybook"4 }5}1# Run all interaction tests2npm run test-storybook3 4# Run tests in specific file5npm run test-storybook -- src/components/Button/Button.stories.tsx6 7# Run tests with coverage8npm run test-storybook -- --coverage9 10# Watch mode for development11npm run test-storybook -- --watch12 13# CI mode (exits with error code on failure)14npm run test-storybook -- --ciCI/CD Integration
Integrating Storybook interaction tests into your CI/CD pipeline ensures component behavior is validated on every commit, preventing regressions from reaching production. This automation catches issues early when they're cheapest to fix.
For enterprise applications with large component libraries, automated testing in CI/CD is essential for maintaining code quality at scale. Each commit triggers your interaction tests, providing immediate feedback to developers.
1name: Interaction Tests2 3on: [push, pull_request]4 5jobs:6 test:7 runs-on: ubuntu-latest8 steps:9 - uses: actions/checkout@v410 11 - name: Setup Node.js12 uses: actions/setup-node@v413 with:14 node-version: '20'15 cache: 'npm'16 17 - name: Install dependencies18 run: npm ci19 20 - name: Build Storybook21 run: npm run build-storybook22 23 - name: Run Interaction Tests24 run: npm run test-storybook -- --ci25 26 - name: Upload test results27 if: failure()28 uses: actions/upload-artifact@v429 with:30 name: test-results31 path: test-results/Debugging Interaction Tests
Storybook's Interactions panel provides a visual debugger for interaction tests. When a test fails, you can replay the interaction sequence step-by-step, inspect component state at each step, and identify exactly where the test diverges from expected behavior.
Common Debugging Techniques
- Add console.log statements: Log component state or variable values during test execution
- Use step() functions: Wrap sections of your play function in named steps for clearer debugging output
- Increase test timeout: Some async operations may need more time:
{ timeout: 10000 } - Snapshot debugging: Use
toJSON()to inspect component tree structure
1export const DebuggingExample: Story = {2 play: async ({ canvasElement }) => {3 const canvas = within(canvasElement);4 5 // Log the canvas HTML for debugging6 console.log('Canvas HTML:', canvasElement.innerHTML);7 8 // Use step for named sections9 await step('Initial state', async () => {10 const button = canvas.getByRole('button');11 console.log('Button state:', button.disabled);12 });13 14 await step('After click', async () => {15 const button = canvas.getByRole('button');16 await userEvent.click(button);17 console.log('After click, button disabled:', button.disabled);18 19 // Verify the click worked20 await expect(button).toBeDisabled();21 });22 },23};Advanced Patterns
When testing components with external dependencies, you'll need to mock API calls, contexts, or external services. Storybook provides several mechanisms for this, allowing you to test components in isolation without relying on real backend services.
1import { http, HttpResponse } from 'msw';2import { setupServer } from 'msw/node';3 4// Mock server for API responses5const server = setupServer(6 http.get('/api/user', () => {7 return HttpResponse.json({8 id: 1,9 name: 'Test User',10 email: '[email protected]'11 });12 }),13 http.post('/api/orders', async ({ request }) => {14 const body = await request.json();15 return HttpResponse.json({ success: true, orderId: '12345' });16 })17);18 19// Enable API mocking in play function20export const WithMockedAPI: Story = {21 play: async ({ canvasElement }) => {22 // Start mock server before test23 server.listen();24 25 const canvas = within(canvasElement);26 27 // Component will receive mocked data28 await canvas.findByRole('heading', { name: /test user/i });29 30 // Clean up after test31 server.close();32 },33};1import { ThemeProvider } from './ThemeContext';2import { AuthProvider } from './AuthContext';3 4const meta: Meta<typeof Dashboard> = {5 title: 'Components/Dashboard',6 component: Dashboard,7 decorators: [8 (Story) => (9 <ThemeProvider initialTheme="dark">10 <AuthProvider initialUser={{ name: 'Test User', role: 'admin' }}>11 <Story />12 </AuthProvider>13 </ThemeProvider>14 ),15 ],16};17 18export const AdminView: Story = {19 play: async ({ canvasElement }) => {20 const canvas = within(canvasElement);21 22 // Verify admin elements are visible23 await expect(canvas.getByRole('button', { name: /delete/i })).toBeInTheDocument();24 await expect(canvas.getByText('Admin Panel')).toBeInTheDocument();25 },26};Conclusion
Storybook's interaction testing system provides a powerful framework for validating component behavior. By combining play functions with Testing Library's familiar APIs, you can write comprehensive tests that verify your components work correctly under real-world user interactions.
Key Takeaways
- Use play functions to define test scenarios directly in your stories, keeping tests and documentation together
- Follow Testing Library's query priority for accessible, maintainable tests that reflect how users interact with your interface
- Leverage the Test Runner for automated, parallel test execution that scales with your component library
- Integrate with CI/CD to catch regressions before they reach production, protecting your users from broken functionality
Start by adding interaction tests to your most critical components--forms, modals, dropdowns, and other interactive elements that users rely on every day. Expand coverage as you develop new features. The investment in writing good interaction tests pays dividends in code quality, documentation value, and developer confidence.
By testing components in isolation with realistic user interactions, you build a safety net that catches bugs early while maintaining living documentation that your team can trust.
Looking to strengthen your overall testing strategy? Our team of experienced developers can help you implement comprehensive web development services including testing frameworks, CI/CD pipelines, and quality assurance processes that protect your investment in custom software.
Sources
-
Storybook Official Documentation - Interaction Testing - Core reference for all interaction testing concepts including play functions, canvas API, and assertion patterns
-
Chromatic - Interaction Tests for User Behavior - How interaction tests integrate with visual testing workflows, debugging capabilities, and CI/CD integration
-
BrowserStack - Storybook Test Runner Guide - Detailed guidance on running Storybook tests in CI environments, browser testing considerations, and integration with testing infrastructure
-
Storybook Blog - Test Component Interactions - Tutorial-style guide covering practical implementation of interaction tests with step-by-step examples
-
Testing Library - Query Priority - Reference for Testing Library query methods and recommended priority for finding elements in tests