Storybook Testing Skill
Generate comprehensive Storybook stories with interactive tests using CSF Next format and .test() method for React
components.
Reference Files:
- mocking.md - Comprehensive mocking guide (functions, modules, APIs, Next.js hooks, Context)
- patterns.md - Testing patterns and examples
- best-practices.md - Best practices, migration guide, and common pitfalls
- examples-and-templates.md - Practical code examples and component test templates
- design-system.md - Testing @szum-tech/design-system components
- api-reference.md - Complete API documentation
Context
This project uses Storybook 10+ with CSF Next format - the latest Component Story Format with factory functions for full type safety.
Stories are used for:
- Component testing - Test components in isolation
- Interaction testing - Verify user interactions (clicks, typing)
- Validation testing - Test form validation and error states
- Accessibility testing - Verify a11y with addon
When to Use This Skill vs Others
| Scenario | Use This Skill? | Alternative |
|---|---|---|
| Test React component UI rendering and interactions | YES | — |
| Test form validation UI (error messages, field states) | YES | — |
| Test component visual states (loading, error, empty) | YES | — |
| Test design system component behavior | YES | — |
| Test pure utility functions (formatCurrency, etc.) | NO | unit-testing |
| Test server actions or API logic | NO | unit-testing |
| Test hooks (useDebounce, etc.) in isolation | NO | unit-testing |
| Test Zod schemas or validation logic | NO | unit-testing |
| Test API endpoints / route handlers | NO | api-test |
| Perform WCAG accessibility audit | NO | accessibility-audit |
| Create mock data builders | NO | builder-factory |
Workflow
- Analyze component - Props, interactions, states, callbacks
- Create story file - Same directory as component:
component.stories.tsx - Write minimal stories - 1-2 stories for different component states
- Add multiple tests - Use
.test()method; ONE content test withstep(), separate.test()per behavior - Run tests -
npm run test:storybook
⭐ Preferred Pattern: .test() Method
IMPORTANT: Use .test() method to add multiple tests to a single story instead of creating separate test stories.
Why .test() Over Multiple Stories?
- ✅ Fewer stories - 80% reduction in story count
- ✅ Better isolation - Each test is independent
- ✅ Clearer intent - Test names describe behavior
- ✅ Better reporting - Individual test results in Storybook UI
- ✅ Less boilerplate - No repeated
meta.story()calls
CRITICAL: userEvent Must Be Destructured from Parameters
Never import userEvent from storybook/test. Always destructure it from the test function parameters.
// ❌ WRONG — breaks Storybook timing integration
import { expect, fn, userEvent } from "storybook/test";
Story.test("Test", async ({ canvas }) => {
await userEvent.click(button);
});
// ✅ CORRECT — properly integrated with Storybook
import { expect, fn } from "storybook/test";
Story.test("Test", async ({ canvas, userEvent }) => {
await userEvent.click(button);
});
Rules:
- Import: Only
expect,fn,waitFor,screenfromstorybook/test - Destructure:
userEvent,canvas,args,stepalways come from the function parameter - Why: The test framework provides these with proper Storybook integration to handle timing correctly
CSF Next Format
CSF Next uses factory functions that provide full type safety:
definePreview → preview.meta → meta.story
Story File Structure (Using .test() Method)
import { expect, fn, waitFor } from "storybook/test";
import preview from "~/.storybook/preview";
import { SubmitButton } from "./submit-button";
const meta = preview.meta({
title: "Components/Submit Button",
component: SubmitButton,
args: {
onClick: fn(),
},
});
// Story with Story suffix to avoid namespace conflict with imported component
export const SubmitButtonStory = meta.story({ name: "Submit Button" });
// Test 1: Rendering
SubmitButtonStory.test(
"Renders button with correct text",
async ({ canvas }) => {
const button = canvas.getByRole("button", { name: /submit/i });
await expect(button).toBeVisible();
},
);
// Test 2: Interaction
SubmitButtonStory.test(
"Clicking button triggers onClick",
async ({ canvas, userEvent, args }) => {
const button = canvas.getByRole("button", { name: /submit/i });
await userEvent.click(button);
await expect(args.onClick).toHaveBeenCalled();
},
);
// Test 3: Accessibility
SubmitButtonStory.test("Button has correct ARIA label", async ({ canvas }) => {
const button = canvas.getByRole("button", { name: /submit/i });
await expect(button).toHaveAccessibleName();
});
Note:
SubmitButtonStoryuses theStorysuffix because this is the only story for this component — the suffix avoids namespace collision with the imported binding. For components with multiple stories, use plain descriptive state names (EmptyForm,FilledForm) without the suffix.
When to Use play Instead of .test()
play has two valid use cases — demos and dependent flows. It should never be used for independent test assertions.
- Demos — Use
playwithout assertions to show component after user interaction in Storybook docs - Complex Dependent Flows (rare ~10%) — Use
playwithstep()when steps depend on each other
See best-practices.md for the full decision matrix, component type guidelines, and code examples.
Key Differences from CSF 3.0
| CSF 3.0 | CSF Next |
|---|---|
import type { Meta, StoryObj } |
import preview from "~/.storybook/preview" |
const meta = { } satisfies Meta<typeof C> |
const meta = preview.meta({ }) |
export default meta |
No default export needed |
type Story = StoryObj<typeof meta> |
Types inferred automatically |
export const Story: Story = { } |
export const Story = meta.story({ }) |
Story Configuration Conventions
Title Field
Always use human-readable, space-separated words in the title field — matching the component's display name:
// ❌ BAD - CamelCase (unreadable in Storybook sidebar)
title: "Components/CountrySelect";
title: "Components/InfoTooltip";
title: "Components/SettingsTabs";
// ✅ GOOD - Spaced words (clean sidebar display)
title: "Components/Country Select";
title: "Components/Info Tooltip";
title: "Components/Settings Tabs";
The title path segments use the same spacing as the name field in the story config.
Story Naming Conventions
Stories represent component states - use descriptive, specific names:
Single Story Components
If a component has only ONE story, use the ComponentNameStory format with a name field — this
avoids namespace collision with the imported component binding:
LoginFormStory+name: "Login Form"— for the LoginForm componentUserCardStory+name: "User Card"— for the UserCard componentSearchInputStory+name: "Search Input"— for the SearchInput component
Multiple Story Components
If component has multiple stories, use descriptive state names:
EmptyForm/FilledForm- Empty vs populated statesLoadingButton/IdleButton- Loading vs idle statesErrorState/SuccessState- Different result statesDisabledInput/EnabledInput- Disabled vs enabled states
Visual Variant Stories
For visual documentation (styles, themes):
Primary/Secondary/Destructive- Button variantsSmall/Medium/Large- Size variantsLight/Dark- Theme variants
❌ Avoid: Generic names like Default, Basic, Example ✅ Prefer: Specific names that describe the component
or state
Tests describe specific behaviors (use .test() method):
"Renders heading and description"- What renders"Shows validation error on empty submit"- Validation behavior"Clicking button triggers callback"- Interaction behavior"Keyboard navigation works with arrow keys"- Accessibility behavior
Examples
Single Story Component
// Component: UserCard
// Story: Named after component with Story suffix to avoid namespace conflict
export const UserCardStory = meta.story({ name: "User Card" });
// Tests: Specific behaviors
UserCardStory.test("Renders user name and avatar", async ({ canvas }) => { ... });
UserCardStory.test("Clicking card triggers onSelect", async ({ canvas }) => { ... });
UserCardStory.test("Shows verified badge for verified users", async ({ canvas }) => { ... });
Multiple Story Component
// Component: LoginForm
// Story 1: Empty form state
export const EmptyForm = meta.story({});
EmptyForm.test("Renders email and password fields", async ({ canvas }) => { ... });
EmptyForm.test("Shows validation on empty submit", async ({ canvas }) => { ... });
// Story 2: Pre-filled form state
export const FilledForm = meta.story({
args: { defaultValues: { email: "user@example.com" } }
});
FilledForm.test("Displays pre-filled email", async ({ canvas }) => { ... });
FilledForm.test("Can modify pre-filled values", async ({ canvas }) => { ... });
Play Function Parameters
canvas- Testing Library queries scoped to componentcanvasElement- Raw DOM element (for portal queries)userEvent- Pre-configured interaction methodsargs- Story args (props)step- Group assertions into named steps
Using Test Builders
Always prefer builders over inline mock data:
import { expect, fn } from "storybook/test";
import preview from "~/.storybook/preview";
import { userBuilder } from "~/features/*/test/builders";
import { UserCard } from "./user-card";
const meta = preview.meta({
component: UserCard,
args: {
onSubmit: fn(),
},
});
// Story suffix avoids namespace conflict with imported UserCard component
export const UserCardStory = meta.story({
name: "User Card",
args: {
user: userBuilder.one(),
},
});
// Multiple tests for that story
UserCardStory.test("Renders user name correctly", async ({ canvas, args }) => {
const name = canvas.getByText(args.user.name);
await expect(name).toBeVisible();
});
UserCardStory.test("Displays user avatar", async ({ canvas }) => {
const avatar = canvas.getByRole("img", { name: /avatar/i });
await expect(avatar).toBeVisible();
});
UserCardStory.test(
"Clicking card triggers callback",
async ({ canvas, userEvent, args }) => {
const card = canvas.getByRole("article");
await userEvent.click(card);
await expect(args.onSubmit).toHaveBeenCalled();
},
);
If builder doesn't exist, invoke /builder-factory skill first.
Running Tests
npm run test:storybook # Run component tests
npm run storybook:dev # View in Storybook UI
Mocking in Storybook
| Mock Type | Tool | Use Case |
|---|---|---|
| Callback props | fn() |
onClick, onSubmit, event handlers |
| External modules | sb.mock() in preview.ts |
uuid, session, analytics |
| REST/GraphQL | MSW http.* / graphql.* |
fetch, axios, API calls |
| Next.js hooks | @storybook/nextjs/navigation.mock |
useRouter, useParams, redirect |
| React Context | Decorators | AuthContext, ThemeProvider |
| Mock data | Builders (/builder-factory) |
User objects, complex data structures |
See mocking.md for complete examples, patterns, and best practices.
Common Mistakes to Avoid
- Importing
userEvent— Always destructure from test parameters, never import fromstorybook/test - Using CSF 3.0 patterns — Use
preview.meta()/meta.story(), notsatisfies Meta<>/export default meta - Separate stories per test — Use
.test()on one story instead of multipleplaystories - Generic story names — Use descriptive names (
EmptyForm,FilledForm), notDefaultorBasic - Using
canvasfor portal content — Usescreenfromstorybook/testfor modals, dropdowns, tooltips
See best-practices.md for detailed examples and fixes for each anti-pattern.
Questions to Ask
Before writing tests, consider:
Interactions & Behavior
- What user interactions should be tested?
- Are there specific edge cases to cover?
- What validation rules should be tested?
- What keyboard navigation should work?
Mocking Requirements
- Functions: Do callbacks need to be mocked with
fn()? - Modules: Are there external dependencies (uuid, analytics) to mock with
sb.mock()? - APIs: Does the component fetch data that needs MSW mocking?
- Next.js: Does it use
useRouter,useParams, oruseSearchParams? - Context: Does it consume React Context that needs mocking?
- Data: Should I use test builders or inline mock data?
Test Coverage
- What are the critical user paths?
- What error states should be tested?
- Are there loading states to verify?
- What accessibility requirements must be met?
Source: janszewczyk/claude-plugins — distributed by TomeVault.