| name | storybook-testing |
| version | 1.1.0 |
| description | Create comprehensive Storybook stories with interactive tests for React components using play functions |
| tags | testing, storybook, react, component-testing, integration-testing, play-function, userEvent |
| author | Szum Tech Team |
| examples | Write Storybook tests for UserProfileCard component, Create story tests for my LoginForm, Add Storybook testing to the ProductCard component, Generate comprehensive Storybook stories with tests for NavBar |
Storybook Testing Skill
Generate comprehensive Storybook stories with interactive tests using play functions for React components. This skill
helps create browser-based integration tests that verify component behavior, user interactions, and accessibility.
Context
This skill helps you write Storybook stories that include interactive tests. Stories are used for:
- Component testing - Test components in isolation with realistic props
- Interaction testing - Verify user interactions (clicks, typing, form submissions)
- Validation testing - Test form validation and error states
- Integration testing - Test component flows and state changes
- Visual testing - Document different component states
- Accessibility testing - Verify component accessibility with a11y addon
Storybook 9+ userEvent Changes:
In Storybook 9 and later, userEvent is provided directly via the play function parameter. This is the recommended approach:
play: async ({ canvas, userEvent }) => {
await userEvent.type(canvas.getByLabelText(/email/i), "test@example.com");
await userEvent.click(canvas.getByRole("button"));
}
You no longer need to call userEvent.setup() for most cases - Storybook handles the setup automatically.
Key Concepts
Storybook Stories Structure:
- Each story represents a specific component state or scenario
- Stories use TypeScript for type safety
- Meta configuration sets up component defaults and decorators
- Play functions contain test assertions and interactions
Testing Philosophy:
- Test user-visible behavior, not implementation details
- Use semantic queries (getByRole, getByLabelText) over test IDs
- Test complete user flows, not just isolated actions
- Include edge cases and error scenarios
Instructions
When the user asks to create Storybook tests for a component:
1. Analyze the Component
Examine the component to identify:
- Props and their types
- User interactions (clicks, form inputs, selections)
- Form validation rules and error states
- Loading states and async behavior
- Conditional rendering logic
- Callbacks/actions (onSubmit, onClick, etc.)
2. Create Story File Structure
File location: Same directory as the component, with .stories.tsx extension
import { type Meta, type StoryObj } from "@storybook/nextjs-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { ComponentName } from "./component-name";
// Import any builders or test utilities needed
import { builderName } from "~/features/*/test/builders";
const meta = {
title: "Features/[FeatureName]/Component Name",
component: ComponentName,
decorators: [
(story) => <div className="w-full max-w-xl">{story()}</div>
],
args: {
// Default args for all stories
onAction: fn(),
// Mock any required props
}
} satisfies Meta<typeof ComponentName>;
export default meta;
type Story = StoryObj<typeof meta>;
3. Story Naming Conventions
Use descriptive story names that indicate the scenario being tested:
Common Story Types:
InitialForm/NoDefaultValues- Empty/initial statePrefilled/PrefilledValues- Component with dataErrorValidation/ValidationEmptyForm- Validation error statesInteraction/UserInteraction- User interaction flowsLoadingState- Async/loading statesCompleteUserFlow- End-to-end scenariosBackNavigation/BackButtonAction- Navigation testsServerErrorHandling- Error handling from server actionsEdgeCaseName- Specific edge cases
4. Writing Play Functions
Play functions are the core of Storybook testing. They contain assertions and interactions.
Basic Structure:
export const StoryName: Story = {
args: {
// Story-specific args that override meta.args
},
play: async ({ canvas, args, step, canvasElement, userEvent }) => {
// Test code here - userEvent is provided by Storybook!
}
};
Play Function Parameters:
canvas- Testing Library queries scoped to the component (within(canvasElement))args- Story args (props passed to component)step- Group assertions into named steps (optional but recommended)canvasElement- Raw DOM element (use for portals)userEvent- Storybook's built-in userEvent instance (recommended for most interactions)
Query Methods (canvas/within):
Use semantic queries from Testing Library:
// Preferred queries (by user-visible content)
canvas.getByRole("button", { name: /submit/i })
canvas.getByLabelText(/email/i)
canvas.getByText(/welcome/i)
canvas.getByPlaceholderText(/enter name/i)
// Query variants
canvas.getBy* // Throws if not found (use for assertions)
canvas.queryBy* // Returns null if not found (use for negative assertions)
canvas.findBy* // Async, waits for element (use for dynamic content)
canvas.getAllBy* // Returns array of matches
Common Assertions:
// Visibility
await expect(element).toBeVisible();
await expect(element).toBeInTheDocument();
await expect(element).not.toBeInTheDocument();
await expect(element).toBeNull();
// State
await expect(checkbox).toBeChecked();
await expect(checkbox).not.toBeChecked();
await expect(button).toBeDisabled();
await expect(button).toBeEnabled();
// Content
await expect(element).toHaveTextContent("text");
await expect(element).toHaveValue("value");
await expect(element).toHaveAttribute("data-state", "loading");
await expect(element).toHaveClass(/w-full/);
// Counts
await expect(elements.length).toBeGreaterThan(0);
await expect(elements.length).toBe(3);
// Function calls (for mocked functions)
await expect(args.onSubmit).toHaveBeenCalled();
await expect(args.onSubmit).toHaveBeenCalledOnce();
await expect(args.onSubmit).toHaveBeenCalledWith({ data: "value" });
await expect(args.onSubmit).not.toHaveBeenCalled();
User Interactions
// Click
await userEvent.click(button);
await userEvent.dblClick(element);
// Typing (basic)
await userEvent.type(input, "text to type");
// Typing with delay (recommended for realistic interactions)
await userEvent.type(input, "text to type", { delay: 100 });
// Clear and type
await userEvent.clear(input);
await userEvent.type(input, "new text");
// Keyboard
await userEvent.tab();
await userEvent.keyboard("{Enter}");
await userEvent.keyboard("{Escape}");
// Hover
await userEvent.hover(element);
await userEvent.unhover(element);
// Select (for native select elements)
await userEvent.selectOptions(select, "optionValue");
IMPORTANT: userEvent from Play Function Parameter vs userEvent.setup()
In Storybook 9+, userEvent is provided directly via the play function parameter. This is the recommended approach as it's pre-configured by Storybook:
// ✅ RECOMMENDED - Use userEvent from play function parameter
export const DirectInteraction: Story = {
play: async ({ canvas, userEvent, step }) => {
// userEvent is already set up by Storybook - just use it!
await step("Fill form", async () => {
const input = canvas.getByLabelText(/name/i);
await userEvent.type(input, "John", { delay: 100 });
await userEvent.tab();
const button = canvas.getByRole("button", { name: /submit/i });
await userEvent.click(button);
});
}
};
Alternative: Import from storybook/test when you need userEvent outside play function:
import { userEvent } from "storybook/test";
// ✅ Also valid - imported userEvent for use anywhere
export const ImportedUserEvent: Story = {
play: async ({ canvas }) => {
await userEvent.type(canvas.getByLabelText(/email/i), "test@example.com");
await userEvent.click(canvas.getByRole("button"));
}
};
Use userEvent.setup() only for advanced configuration:
// ⚠️ Use setup() ONLY when you need custom configuration
export const ComplexInteraction: Story = {
play: async ({ canvas, step }) => {
// setup() when you need custom options like skipHover, delay, etc.
const user = userEvent.setup({
delay: 50, // Custom delay between events
skipHover: true, // Skip hover in click()
});
await step("Complex multi-step interaction", async () => {
const input = canvas.getByLabelText(/name/i);
await user.clear(input);
await user.type(input, "John Doe");
await user.tab();
const checkbox = canvas.getByRole("checkbox");
await user.click(checkbox);
});
}
};
When to use which approach:
userEventfrom play parameter (recommended):- Default choice for all Storybook tests
- Pre-configured by Storybook
- Cleaner, more readable code
- Works for most use cases
Imported
userEventfromstorybook/test:- When you need userEvent outside play function context
- For helper functions or utilities
userEvent.setup()(use sparingly):- When you need custom configuration (delay, skipHover, etc.)
- Advanced scenarios requiring shared keyboard/pointer state
- Performance-critical scenarios with many interactions
Waiting for Changes:
// Wait for condition to be true
await waitFor(async () => {
const element = canvas.getByText(/success/i);
await expect(element).toBeVisible();
});
// Wait for element to appear (alternative)
const element = await canvas.findByText(/success/i);
await expect(element).toBeVisible();
// Wait with custom timeout
await waitFor(
async () => {
await expect(condition).toBe(true);
},
{ timeout: 5000 }
);
5. Testing Patterns
Initial State Testing
Test the component's default state:
export const InitialForm: Story = {
play: async ({ canvas, step, userEvent }) => {
await step("Verify initial field visibility", async () => {
const input = canvas.getByLabelText(/email/i);
await expect(input).toBeVisible();
await expect(input).toHaveValue("");
});
await step("Verify default button state", async () => {
const button = canvas.getByRole("button", { name: /submit/i });
await expect(button).toBeVisible();
await expect(button).toBeEnabled();
});
}
};
Prefilled/Default Values Testing
Test component with data:
export const Prefilled: Story = {
args: {
defaultValues: {
email: "user@example.com",
name: "John Doe"
}
},
play: async ({ canvas, args }) => {
const emailInput = canvas.getByLabelText(/email/i);
await expect(emailInput).toHaveValue(args.defaultValues?.email);
const nameInput = canvas.getByLabelText(/name/i);
await expect(nameInput).toHaveValue(args.defaultValues?.name);
}
};
Validation Error Testing
Test form validation:
export const ValidationEmptyForm: Story = {
args: {
onSubmit: fn()
},
play: async ({ canvas, args, userEvent }) => {
// Submit without filling fields
const submitButton = canvas.getByRole("button", { name: /submit/i });
await userEvent.click(submitButton);
// Verify error messages appear
await waitFor(async () => {
const errorMessage = canvas.getByText(/required/i);
await expect(errorMessage).toBeInTheDocument();
});
// Verify onSubmit was NOT called
await expect(args.onSubmit).not.toHaveBeenCalled();
}
};
User Interaction Flow Testing
Test complete user flows:
export const Interaction: Story = {
args: {
onSubmit: fn()
},
play: async ({ canvas, args, userEvent }) => {
// Step 1: Fill in form (using delay for realistic typing)
const emailInput = canvas.getByLabelText(/email/i);
await userEvent.type(emailInput, "user@example.com", { delay: 100 });
const passwordInput = canvas.getByLabelText(/password/i);
await userEvent.type(passwordInput, "password123", { delay: 100 });
// Step 2: Submit form
const submitButton = canvas.getByRole("button", { name: /submit/i });
await userEvent.click(submitButton);
// Step 3: Verify submission
await waitFor(async () => {
await expect(args.onSubmit).toHaveBeenCalledWith({
email: "user@example.com",
password: "password123"
});
});
}
};
Loading State Testing
Test async behavior:
export const LoadingState: Story = {
args: {
onSubmit: async () =>
new Promise((resolve) => {
setTimeout(() => resolve(null as never), 2000);
})
},
play: async ({ canvas, userEvent }) => {
const submitButton = canvas.getByRole("button", { name: /submit/i });
await userEvent.click(submitButton);
// Verify loading state
await expect(submitButton).toBeDisabled();
await expect(submitButton).toHaveAttribute("data-state", "loading");
}
};
Portal/Dropdown Testing
Test elements rendered in portals (modals, dropdowns):
export const DropdownInteraction: Story = {
play: async ({ canvas, canvasElement, userEvent }) => {
// Click trigger to open dropdown
const trigger = canvas.getByLabelText("Select option");
await userEvent.click(trigger);
// Portal elements are outside canvas, use parent element
const portalElement = canvasElement.parentElement as HTMLElement;
const portal = within(portalElement);
// Wait for portal content and interact
await waitFor(async () => {
const option = portal.getByRole("option", { name: /option 1/i });
await expect(option).toBeVisible();
await userEvent.click(option);
});
// Verify selection
await expect(trigger).toHaveTextContent("Option 1");
}
};
Navigation/Action Testing
Test callbacks and navigation:
export const BackNavigation: Story = {
args: {
onBack: fn()
},
play: async ({ canvas, args, userEvent }) => {
const backButton = canvas.getByRole("button", { name: /back/i });
await userEvent.click(backButton);
await expect(args.onBack).toHaveBeenCalledOnce();
}
};
Error Handling Testing
Test server error scenarios:
export const ServerErrorHandling: Story = {
args: {
onSubmit: fn(async () => ({
success: false as const,
error: "Failed to save. Please try again."
}))
},
play: async ({ canvas, args, userEvent }) => {
const submitButton = canvas.getByRole("button", { name: /submit/i });
await userEvent.click(submitButton);
// Verify action was called
await waitFor(async () => {
await expect(args.onSubmit).toHaveBeenCalled();
});
// Note: Toast/alert verification requires additional setup
// The component should display error in UI or toast
}
};
Complete User Flow Testing
Test end-to-end scenarios:
export const CompleteUserFlow: Story = {
args: {
onSubmit: fn()
},
play: async ({ canvas, args, userEvent, step }) => {
await step("Verify initial state", async () => {
await expect(canvas.getByText("Welcome")).toBeInTheDocument();
});
await step("Fill form fields", async () => {
await userEvent.type(canvas.getByLabelText(/email/i), "user@example.com", { delay: 100 });
await userEvent.type(canvas.getByLabelText(/password/i), "securePass123", { delay: 100 });
});
await step("Accept terms", async () => {
await userEvent.click(canvas.getByRole("checkbox", { name: /accept terms/i }));
});
await step("Submit", async () => {
await userEvent.click(canvas.getByRole("button", { name: /sign up/i }));
});
await step("Verify success", async () => {
await waitFor(async () => {
await expect(args.onSubmit).toHaveBeenCalledWith({
email: "user@example.com",
password: "securePass123",
acceptedTerms: true
});
});
});
}
};
6. Using Steps for Organization
Group related assertions into named steps for better test reporting:
export const Interaction: Story = {
play: async ({ canvas, step, userEvent }) => {
await step("Fill in user information", async () => {
const nameInput = canvas.getByLabelText(/name/i);
await userEvent.type(nameInput, "John Doe", { delay: 100 });
await expect(nameInput).toHaveValue("John Doe");
});
await step("Select preferences", async () => {
const checkbox = canvas.getByRole("checkbox", { name: /newsletter/i });
await userEvent.click(checkbox);
await expect(checkbox).toBeChecked();
});
await step("Submit form", async () => {
const button = canvas.getByRole("button", { name: /submit/i });
await userEvent.click(button);
});
}
};
7. Mocking Functions with fn()
Mock component callbacks and actions:
const meta = {
component: MyForm,
args: {
// Mock with default return value
onSubmit: fn(),
// Mock with specific return value
onSubmit: fn(async () => ({ success: true })),
// Mock with RedirectAction type
onSubmit: fn(
() =>
({
success: true
}) as unknown as RedirectAction
),
// Mock that throws error
onError: fn(() => {
throw new Error("Test error");
})
}
} satisfies Meta<typeof MyForm>;
8. Using Test Builders
Use test-data-bot builders for consistent test data:
import { userBuilder, productBuilder } from "~/features/*/test/builders";
export const WithTestData: Story = {
args: {
user: userBuilder.one(),
products: productBuilder.many(4),
onSubmit: fn()
}
};
9. Story Documentation
Add JSDoc comments to explain each story:
/**
* Initial state of the form with no data filled in.
* Shows empty fields with placeholders.
* Tests that required fields show validation errors on submit.
*/
export const InitialForm: Story = {
// ...
};
/**
* Form prefilled with valid user data.
* Tests successful submission with default values.
*/
export const Prefilled: Story = {
// ...
};
10. Common Story Categories to Create
For each component, consider creating stories for:
- Initial/Default State - Empty component, no data
- Prefilled State - Component with data
- Loading State - Async operations in progress
- Error State - Validation errors, server errors
- Success State - Successful operations
- Edge Cases - Empty lists, max values, special characters
- User Interactions - Clicks, typing, selections
- Complete Flows - End-to-end user scenarios
- Accessibility - Keyboard navigation, screen reader support
- Responsive - Different viewport sizes (if applicable)
Best Practices
Use Semantic Queries
- Prefer
getByRole,getByLabelTextovergetByTestId - Query by user-visible text with regex:
getByText(/welcome/i) - Use case-insensitive matching:
/text/i
- Prefer
Await Async Operations
- Always
awaituser interactions - Use
waitForfor dynamic content - Use
findBy*queries for elements that appear asynchronously
- Always
Test User-Visible Behavior
- Don't test implementation details
- Test what users see and do
- Verify outcomes, not internal state
Mock External Dependencies
- Use
fn()to mock callbacks - Mock server actions with return values
- Use builders for test data
- Use
Organize Tests with Steps
- Use
step()to group related assertions - Makes test reports more readable
- Documents test flow
- Use
Handle Portals Correctly
- Use
canvasElement.parentElementfor portal content - Create new
within()context for portal - Wait for portal content with
waitFor
- Use
Verify Function Calls
- Always verify mocked functions were (or weren't) called
- Check call arguments for correctness
- Use
.toHaveBeenCalledOnce(),.toHaveBeenCalledWith()
Include Edge Cases
- Empty forms
- Invalid inputs
- Server errors
- Loading states
- Disabled states
Document Stories
- Add JSDoc comments explaining scenarios
- Use descriptive story names
- Group related stories together
Keep Stories Focused
- One scenario per story
- Avoid testing multiple unrelated things
- Create separate stories for different states
Common Pitfalls to Avoid
Not awaiting async operations
// ❌ Wrong userEvent.click(button); expect(args.onSubmit).toHaveBeenCalled(); // ✅ Correct await userEvent.click(button); await expect(args.onSubmit).toHaveBeenCalled();Not using waitFor for dynamic content
// ❌ Wrong const message = canvas.getByText(/success/i); await expect(message).toBeVisible(); // ✅ Correct await waitFor(async () => { const message = canvas.getByText(/success/i); await expect(message).toBeVisible(); });Using getBy* for elements that might not exist
// ❌ Wrong (throws error if not found) const error = canvas.getByText(/error/i); await expect(error).toBeNull(); // This will never execute // ✅ Correct (returns null if not found) const error = canvas.queryByText(/error/i); await expect(error).toBeNull();Not handling portals correctly
// ❌ Wrong (portal content not in canvas) await userEvent.click(dropdownTrigger); const option = canvas.getByRole("option", { name: /option/i }); // Won't find it // ✅ Correct await userEvent.click(dropdownTrigger); const portal = within(canvasElement.parentElement as HTMLElement); const option = portal.getByRole("option", { name: /option/i });Not mocking functions properly
// ❌ Wrong (no mock) args: { onSubmit: async () => {}; // Not trackable } // ✅ Correct (with fn()) args: { onSubmit: fn(async () => ({ success: true })); }
Integration with Project
File Structure:
features/
feature-name/
components/
forms/
my-form.tsx
my-form.stories.tsx ← Stories here
test/
builders/
my-form-data.builder.ts ← Test data builders
Running Tests:
npm run test:storybook # Run Storybook component tests
npm run storybook:dev # View stories in Storybook UI
Test Environment:
- Tests run in Chromium browser via Playwright
- Setup:
tests/integration/vitest.setup.ts - Uses @storybook/test for testing utilities
- Includes accessibility addon (@storybook/addon-a11y)
Example Template
import { type Meta, type StoryObj } from "@storybook/nextjs-vite";
import { expect, fn, waitFor, within } from "storybook/test";
import { MyComponent } from "./my-component";
const meta = {
title: "Features/[Feature]/My Component",
component: MyComponent,
decorators: [(story) => <div className="w-full max-w-xl">{story()}</div>],
args: {
onAction: fn()
}
} satisfies Meta<typeof MyComponent>;
export default meta;
type Story = StoryObj<typeof meta>;
/**
* Initial state of the component.
*/
export const InitialState: Story = {
play: async ({ canvas, step, userEvent }) => {
await step("Verify initial render", async () => {
const element = canvas.getByRole("button", { name: /click me/i });
await expect(element).toBeVisible();
});
}
};
/**
* User interaction flow.
* Uses userEvent from play function parameter (recommended approach in Storybook 9+).
*/
export const Interaction: Story = {
args: {
onAction: fn()
},
play: async ({ canvas, args, userEvent }) => {
const button = canvas.getByRole("button", { name: /click me/i });
await userEvent.click(button);
await expect(args.onAction).toHaveBeenCalledOnce();
}
};
Workflow
- User requests Storybook tests for a component
- Analyze component - Identify props, interactions, states
- Create story file - Set up meta configuration
- Write stories - Cover different scenarios
- Add play functions - Write test assertions
- Document stories - Add JSDoc comments
- Run tests - Verify stories pass:
npm run test:storybook
Questions to Ask
When unclear about implementation, ask:
- What user interactions should be tested?
- Are there specific edge cases to cover?
- What validation rules should be tested?
- Are there server actions that need mocking?
- Should we test loading/error states?
- Are there accessibility requirements?
- What are the expected outcomes for each interaction?