Storybook 8 Testing: play Functions, Portable Stories, and Vitest Integration
Storybook 8 expanded from a documentation tool into a component testing platform. The key additions are play functions (interaction tests that run inside the browser), portable stories (importing stories into Vitest or Jest), and the Storybook test runner that executes all play functions in CI. Together they let you write tests once and run them both in the browser (for debugging) and in headless CI.
Play Functions
A play function runs after a story renders. It uses @storybook/test (which wraps Vitest's expect and @testing-library/user-event) to simulate user interactions and assert on results:
// LoginForm.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { LoginForm } from './LoginForm';
const meta: Meta<typeof LoginForm> = {
component: LoginForm,
};
export default meta;
type Story = StoryObj<typeof LoginForm>;
export const EmptySubmit: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Click submit without filling the form
await userEvent.click(canvas.getByRole('button', { name: 'Sign in' }));
// Assert validation errors appear
await expect(canvas.getByText('Email is required')).toBeInTheDocument();
await expect(canvas.getByText('Password is required')).toBeInTheDocument();
},
};
export const InvalidCredentials: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Email'), 'wrong@example.com');
await userEvent.type(canvas.getByLabelText('Password'), 'wrongpassword');
await userEvent.click(canvas.getByRole('button', { name: 'Sign in' }));
await expect(
canvas.getByText('Invalid email or password')
).toBeInTheDocument();
},
};
export const SuccessfulLogin: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Email'), 'alice@example.com');
await userEvent.type(canvas.getByLabelText('Password'), 'correct-password');
await userEvent.click(canvas.getByRole('button', { name: 'Sign in' }));
await expect(canvas.getByText('Welcome back, Alice')).toBeInTheDocument();
},
};In the Storybook UI, play functions run when the story loads and show pass/fail results in the Interactions panel. Failed assertions highlight the specific step and show the error.
Args and Loaders for Test Setup
Use args to configure mock behavior, and loaders for async setup:
export const WithMockedAPI: Story = {
args: {
onLogin: async (credentials: Credentials) => {
// Simulated API response
if (credentials.email === 'alice@example.com') {
return { user: { name: 'Alice', role: 'admin' } };
}
throw new Error('Invalid credentials');
},
},
};
export const WithSlowNetwork: Story = {
loaders: [
async () => ({
// Simulate slow API
delay: await new Promise(resolve => setTimeout(resolve, 2000)),
}),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Check loading state is shown during the delay
await expect(canvas.getByText('Signing in...')).toBeInTheDocument();
},
};Portable Stories with Vitest
Storybook 8 introduced portable stories: import stories into Vitest (or Jest) and render them with Testing Library. This lets you run interaction tests outside the browser:
// LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import { composeStory } from '@storybook/react';
import meta, { EmptySubmit, SuccessfulLogin } from './LoginForm.stories';
// Compose the story with its meta (applies decorators, args, etc.)
const EmptySubmitStory = composeStory(EmptySubmit, meta);
const SuccessfulLoginStory = composeStory(SuccessfulLogin, meta);
test('empty submit shows validation errors', async () => {
const { container } = render(<EmptySubmitStory />);
// play function runs automatically when using composeStory
await EmptySubmitStory.play({ canvasElement: container });
expect(screen.getByText('Email is required')).toBeInTheDocument();
});
test('successful login shows welcome message', async () => {
const { container } = render(<SuccessfulLoginStory />);
await SuccessfulLoginStory.play({ canvasElement: container });
expect(screen.getByText('Welcome back, Alice')).toBeInTheDocument();
});composeStory applies decorators, default args, and meta configuration — the story renders exactly as it does in Storybook.
Vitest Plugin (Storybook 8.1+)
For deeper Vitest integration, use the Storybook Vitest plugin. It runs stories as Vitest test files without a browser:
npm install --save-dev @storybook/experimental-addon-testvitest.config.ts:
import { defineConfig } from 'vitest/config';
import { storybookTest } from '@storybook/experimental-addon-test/vitest-plugin';
export default defineConfig({
plugins: [
storybookTest({
configDir: '.storybook',
}),
],
test: {
browser: {
enabled: true,
name: 'chromium',
provider: 'playwright',
},
setupFiles: ['.storybook/vitest.setup.ts'],
},
});.storybook/vitest.setup.ts:
import { setProjectAnnotations } from '@storybook/react';
import * as projectAnnotations from './preview';
setProjectAnnotations(projectAnnotations);With this setup, npx vitest discovers and runs all story files as tests. Play functions execute in Playwright's browser context, giving you the full DOM environment.
Storybook Test Runner
The test runner uses Playwright to open each story in a headless browser and run its play function:
npm install --save-dev @storybook/test-runner# Run all story tests
npx test-storybook
<span class="hljs-comment"># Run specific stories
npx test-storybook --testPathPattern LoginFormGitHub Actions:
- name: Run Storybook tests
run: |
npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
"npx storybook dev --port 6006 --quiet" \
"npx wait-on tcp:6006 && npx test-storybook"The test runner also runs accessibility checks if the a11y addon is installed — each story gets an automatic axe scan.
Accessibility Testing in Stories
// Button.stories.tsx
export const IconOnly: Story = {
render: () => <Button icon="close" />, // Missing aria-label
parameters: {
a11y: {
// Configure axe rules
config: {
rules: [{ id: 'button-name', enabled: true }],
},
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Storybook test runner runs axe on this story automatically
// The missing aria-label will cause an a11y failure
},
};Storybook 8 Performance
Storybook 8 switches from Webpack to Vite by default for new projects. For existing Webpack projects, migrate:
npx storybook@latest upgradeWith Vite, cold start drops from 30–90 seconds to 2–8 seconds for most projects.
Story-Driven Test Strategy
The Storybook 8 testing model:
- Write stories for all meaningful component states
- Add play functions for interaction tests (clicks, form submission, keyboard navigation)
- Run in browser during development for visual feedback
- Run in CI with test runner for automated regression detection
- Import as portable stories in Vitest for unit test pipelines that don't require a browser
This keeps component tests in one place (stories) and removes the duplication between Storybook stories and separate test files.
Key Points
playfunctions use@storybook/test(userEvent,expect,within) to simulate interactions in the rendered storycomposeStoryimports stories into Vitest/Jest with full decorator and arg resolution- The Storybook test runner uses Playwright to run all play functions headlessly in CI
- Storybook 8 defaults to Vite — cold start drops from minutes to seconds
- The Vitest plugin (
@storybook/experimental-addon-test) runs stories as Vitest tests in a browser context - a11y addon + test runner automatically runs axe on every story in CI
- Use
loadersfor async test setup andargsto configure mock behavior per story