Testing React Components in Storybook Using Testing Library
I used to treat Storybook and tests as two separate things. Stories for visual review. Tests for logic. Then I realized I was duplicating work. Every stor...
29 Apr 2024

I used to treat Storybook and tests as two separate things. Stories for visual review. Tests for logic. Then I realized I was duplicating work. Every story already sets up a component in a specific state. Why not test that state directly?
Storybook's Testing Library integration lets you write interaction tests inside your stories. You get visual documentation and automated tests from the same file. One source of truth.
Testing a Button component
Here is a Button component with a few variants: default, themed, and with a click handler.
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('renders with the correct text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('applies the theme class', () => {
render(<Button theme="primary">Submit</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('btn-primary');
});
it('calls onClick when clicked', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledOnce();
});
it('is disabled when the disabled prop is set', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick} disabled>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
expect(screen.getByRole('button')).toBeDisabled();
});
});
Writing the same tests in Storybook
With @storybook/testing-library and the play function, these become interaction tests that run inside the Storybook UI.
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
};
export default meta;
type Story = StoryObj<typeof Button>;
export const ClickTest: Story = {
args: { children: 'Click me' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await userEvent.click(button);
await expect(button).toBeInTheDocument();
},
};
export const DisabledState: Story = {
args: { children: 'Submit', disabled: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await expect(button).toBeDisabled();
},
};
The play function runs automatically when the story loads. You see the test execute in the browser. If it fails, Storybook shows the error right there.
When this approach works well
- Design systems. Every component already has stories. Adding interaction tests is minimal extra effort.
- Accessibility checks. You can query by role and assert ARIA attributes in the same place you visually inspect components.
- Edge cases. Loading states, error states, empty states. Each story is a test scenario.
When it does not
- Complex integration tests. If you need to test multiple components interacting across routes or with real API calls, Storybook is the wrong tool. Use Playwright or Cypress.
- Performance-sensitive CI. Storybook interaction tests run in a browser. They are slower than Vitest or Jest running in Node. For large suites, this adds up.
My recommendation
Use Storybook tests for component-level behavior. Use a proper test runner for everything else. The goal is not to pick one tool. It is to use each tool where it is strongest.