Problem: Manual Testing Wastes Your Time
You open Storybook, click buttons, fill forms, and verify outputs manually every time you change component logic. This takes 5-10 minutes per component and you forget edge cases.
You'll learn:
- How to write interaction tests in story files
- Test clicks, inputs, and async behavior automatically
- Run tests in CI without extra test infrastructure
Time: 20 min | Level: Intermediate
Why This Happens
Storybook shows components in isolation, but you still need to interact with them manually to verify behavior. Traditional testing tools like Jest require duplicating your component setup code.
Common symptoms:
- Clicking through the same flows repeatedly
- Bugs slip through because manual testing is inconsistent
- Jest tests don't match how components actually render in Storybook
Solution
Step 1: Install Testing Dependencies
Storybook 8+ includes interaction testing built-in. Install the test utilities:
npm install @storybook/test --save-dev
Expected: Package installs without peer dependency warnings
If it fails:
- Error: "Cannot find @storybook/test": Update to Storybook 8.0+
- Peer dependency issues: Run
npm install @storybook/addon-interactions --save-dev
Step 2: Write Your First Interaction Test
Here's a login form component that needs testing:
// LoginForm.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { LoginForm } from './LoginForm';
const meta: Meta<typeof LoginForm> = {
title: 'Forms/LoginForm',
component: LoginForm,
};
export default meta;
type Story = StoryObj<typeof LoginForm>;
// Basic story shows initial state
export const Default: Story = {};
// Interaction test simulates user behavior
export const SuccessfulLogin: Story = {
play: async ({ canvasElement }) => {
// Query elements within this story's canvas
const canvas = within(canvasElement);
// Simulate user typing
const emailInput = canvas.getByLabelText('Email');
await userEvent.type(emailInput, 'user@example.com');
const passwordInput = canvas.getByLabelText('Password');
await userEvent.type(passwordInput, 'password123');
// Click submit
const submitButton = canvas.getByRole('button', { name: /sign in/i });
await userEvent.click(submitButton);
// Verify outcome - success message appears
await expect(canvas.getByText('Welcome back!')).toBeInTheDocument();
},
};
Why this works: The play function runs after the component renders. within(canvasElement) scopes queries to this specific story, preventing cross-story conflicts.
Step 3: Test Error States
Add tests for validation failures:
export const InvalidEmail: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Email'), 'not-an-email');
await userEvent.type(canvas.getByLabelText('Password'), 'pass');
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
// Error message should appear without navigation
await expect(canvas.getByText('Please enter a valid email')).toBeInTheDocument();
},
};
export const EmptyFields: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Click submit without filling fields
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
// Both field errors should show
await expect(canvas.getByText('Email is required')).toBeInTheDocument();
await expect(canvas.getByText('Password is required')).toBeInTheDocument();
},
};
Expected: Each story runs independently. Errors display correctly without affecting other tests.
If it fails:
- Error: "Element not found": Check label text matches exactly (case-sensitive)
- Test passes but UI is wrong: Add
awaitbeforeexpectto wait for async updates
Step 4: Test Async Behavior
Components often fetch data or show loading states:
export const LoadingState: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com');
await userEvent.type(canvas.getByLabelText('Password'), 'password123');
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
// Loading spinner appears immediately
expect(canvas.getByText('Signing in...')).toBeInTheDocument();
// Wait for async operation to complete
await expect(canvas.findByText('Welcome back!')).resolves.toBeInTheDocument();
// Spinner should be gone
expect(canvas.queryByText('Signing in...')).not.toBeInTheDocument();
},
};
Why findBy: It waits up to 1 second for the element to appear, handling async state changes. Use getBy for immediate checks, findBy for elements that appear later.
Step 5: Mock API Calls
Real API calls make tests slow and flaky. Mock them in stories:
import { http, HttpResponse } from 'msw';
export const NetworkError: Story = {
parameters: {
msw: {
handlers: [
http.post('/api/login', () => {
// Simulate server error
return HttpResponse.json(
{ error: 'Server unavailable' },
{ status: 503 }
);
}),
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com');
await userEvent.type(canvas.getByLabelText('Password'), 'password123');
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
// Network error message displays
await expect(canvas.findByText('Server unavailable')).resolves.toBeInTheDocument();
},
};
Setup required: Install MSW for Storybook:
npm install msw msw-storybook-addon --save-dev
npx msw init public/
Add to .storybook/preview.ts:
import { initialize, mswLoader } from 'msw-storybook-addon';
initialize();
export const loaders = [mswLoader];
Step 6: Run Tests in CI
Interaction tests run automatically with Storybook's test runner:
npm install @storybook/test-runner --save-dev
Add to package.json:
{
"scripts": {
"test:storybook": "test-storybook"
}
}
GitHub Actions workflow:
name: Storybook Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- run: npm run build-storybook
# Run tests against static build
- run: npx http-server storybook-static --port 6006 &
- run: npm run test:storybook
Why static build: Tests run faster against pre-built Storybook. Dev server works too but adds startup time.
Verification
Start Storybook and open the Interactions panel:
npm run storybook
You should see:
- All stories render correctly
- Interactions panel shows step-by-step test execution
- Green checkmarks for passing assertions
- Test reruns automatically when you edit code
Run tests in Terminal:
npm run test:storybook
Expected output:
âœ" Forms/LoginForm: Successful Login (543ms)
âœ" Forms/LoginForm: Invalid Email (312ms)
âœ" Forms/LoginForm: Empty Fields (289ms)
âœ" Forms/LoginForm: Loading State (678ms)
âœ" Forms/LoginForm: Network Error (445ms)
Test Suites: 1 passed, 1 total
Tests: 5 passed, 5 total
What You Learned
- The
playfunction automates interactions that you'd do manually within(canvasElement)prevents test conflicts between stories- Use
findByqueries for async elements,getByfor immediate checks - MSW mocks API calls so tests run fast and reliably
Limitation: Interaction tests run in the browser, so they don't check server-side rendering behavior. Use Playwright or Cypress for full E2E flows.
When NOT to use this:
- Testing business logic (use Jest instead)
- Multi-page flows (use E2E tests)
- Accessibility audits (use axe-core addon)
Common Patterns
Testing Form Validation
export const PasswordTooShort: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Password'), 'abc');
await userEvent.tab(); // Trigger blur validation
await expect(canvas.getByText('Password must be at least 8 characters'))
.toBeInTheDocument();
},
};
Testing Keyboard Navigation
export const KeyboardSubmit: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const emailInput = canvas.getByLabelText('Email');
await userEvent.type(emailInput, 'user@example.com{Tab}');
await userEvent.type(canvas.getByLabelText('Password'), 'password123{Enter}');
// Form submits via Enter key
await expect(canvas.findByText('Welcome back!')).resolves.toBeInTheDocument();
},
};
Testing Conditional Rendering
export const ShowPasswordToggle: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const passwordInput = canvas.getByLabelText('Password') as HTMLInputElement;
expect(passwordInput.type).toBe('password'); // Hidden initially
const toggleButton = canvas.getByLabelText('Show password');
await userEvent.click(toggleButton);
expect(passwordInput.type).toBe('text'); // Now visible
},
};
Debugging Failed Tests
Add delays to see what's happening:
export const DebugExample: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Email'), 'test@example.com', {
delay: 100, // Type slower for debugging
});
// Pause execution to inspect state
await canvas.getByRole('button').click();
await new Promise(resolve => setTimeout(resolve, 2000));
},
};
Check the Interactions panel's step-by-step breakdown to see exactly where tests fail.
Troubleshooting
"Cannot find element" errors:
- Use
canvas.debug()to print current DOM - Check if element renders conditionally
- Verify label text matches exactly (case-sensitive)
Tests pass but UI looks broken:
- Tests verify logic, not visuals
- Add visual regression tests with Chromatic
- Check browser console for React warnings
Slow test execution:
- Avoid real API calls (use MSW mocks)
- Don't use fixed delays (
await sleep(1000)) - Use
findByqueries instead ofwaitForloops
Tests fail in CI but pass locally:
- Check Node.js version matches (22.x recommended)
- Ensure static build completes before running tests
- Increase timeout for slower CI machines:
test-storybook --maxWorkers=2
Tested on Storybook 8.4, React 19, Node.js 22.x, macOS & Ubuntu