| name | storybook-testing |
| version | 1.0.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"] |
| 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
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 { 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: {
onAction: fn(),
}
} 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 state
Prefilled / PrefilledValues - Component with data
ErrorValidation / ValidationEmptyForm - Validation error states
Interaction / UserInteraction - User interaction flows
LoadingState - Async/loading states
CompleteUserFlow - End-to-end scenarios
BackNavigation / BackButtonAction - Navigation tests
ServerErrorHandling - Error handling from server actions
EdgeCaseName - 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: {
},
play: async ({ canvas, args, step, canvasElement }) => {
}
};
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)
Query Methods (canvas/within):
Use semantic queries from Testing Library:
canvas.getByRole("button", { name: /submit/i })
canvas.getByLabelText(/email/i)
canvas.getByText(/welcome/i)
canvas.getByPlaceholderText(/enter name/i)
canvas.getBy*
canvas.queryBy*
canvas.findBy*
canvas.getAllBy*
Common Assertions:
await expect(element).toBeVisible();
await expect(element).toBeInTheDocument();
await expect(element).not.toBeInTheDocument();
await expect(element).toBeNull();
await expect(checkbox).toBeChecked();
await expect(checkbox).not.toBeChecked();
await expect(button).toBeDisabled();
await expect(button).toBeEnabled();
await expect(element).toHaveTextContent("text");
await expect(element).toHaveValue("value");
await expect(element).toHaveAttribute("data-state", "loading");
await expect(element).toHaveClass(/w-full/);
await expect(elements.length).toBeGreaterThan(0);
await expect(elements.length).toBe(3);
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:
await userEvent.click(button);
await userEvent.dblClick(element);
await userEvent.type(input, "text to type");
await userEvent.clear(input);
await userEvent.type(input, "new text");
await userEvent.tab();
await userEvent.keyboard("{Enter}");
await userEvent.keyboard("{Escape}");
await userEvent.hover(element);
await userEvent.unhover(element);
await userEvent.selectOptions(select, "optionValue");
IMPORTANT: Direct userEvent vs userEvent.setup()
Prefer direct userEvent calls for most interactions:
export const DirectInteraction: Story = {
play: async ({ canvas, step }) => {
await step("Fill form", async () => {
const input = canvas.getByLabelText(/name/i);
await userEvent.type(input, "John");
await userEvent.tab();
const button = canvas.getByRole("button", { name: /submit/i });
await userEvent.click(button);
});
}
};
Only use userEvent.setup() when you need advanced configuration or multiple complex interactions:
export const ComplexInteraction: Story = {
play: async ({ canvas, step }) => {
const user = userEvent.setup();
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:
Waiting for Changes:
await waitFor(async () => {
const element = canvas.getByText(/success/i);
await expect(element).toBeVisible();
});
const element = await canvas.findByText(/success/i);
await expect(element).toBeVisible();
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 }) => {
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 }) => {
const submitButton = canvas.getByRole("button", { name: /submit/i });
await userEvent.click(submitButton);
await waitFor(async () => {
const errorMessage = canvas.getByText(/required/i);
await expect(errorMessage).toBeInTheDocument();
});
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 }) => {
const user = userEvent.setup();
const emailInput = canvas.getByLabelText(/email/i);
await user.type(emailInput, "user@example.com");
const passwordInput = canvas.getByLabelText(/password/i);
await user.type(passwordInput, "password123");
const submitButton = canvas.getByRole("button", { name: /submit/i });
await user.click(submitButton);
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 }) => {
const submitButton = canvas.getByRole("button", { name: /submit/i });
await userEvent.click(submitButton);
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 }) => {
const trigger = canvas.getByLabelText("Select option");
await userEvent.click(trigger);
const portalElement = canvasElement.parentElement as HTMLElement;
const portal = within(portalElement);
await waitFor(async () => {
const option = portal.getByRole("option", { name: /option 1/i });
await expect(option).toBeVisible();
await userEvent.click(option);
});
await expect(trigger).toHaveTextContent("Option 1");
}
};
Navigation/Action Testing
Test callbacks and navigation:
export const BackNavigation: Story = {
args: {
onBack: fn()
},
play: async ({ canvas, args }) => {
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 }) => {
const submitButton = canvas.getByRole("button", { name: /submit/i });
await userEvent.click(submitButton);
await waitFor(async () => {
await expect(args.onSubmit).toHaveBeenCalled();
});
}
};
Complete User Flow Testing
Test end-to-end scenarios:
export const CompleteUserFlow: Story = {
args: {
onSubmit: fn()
},
play: async ({ canvas, args }) => {
const user = userEvent.setup();
await expect(canvas.getByText("Welcome")).toBeInTheDocument();
await user.type(canvas.getByLabelText(/email/i), "user@example.com");
await user.type(canvas.getByLabelText(/password/i), "securePass123");
await user.click(canvas.getByRole("checkbox", { name: /accept terms/i }));
await user.click(canvas.getByRole("button", { name: /sign up/i }));
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 }) => {
await step("Fill in user information", async () => {
const nameInput = canvas.getByLabelText(/name/i);
await userEvent.type(nameInput, "John Doe");
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: {
onSubmit: fn(),
onSubmit: fn(async () => ({ success: true })),
onSubmit: fn(
() =>
({
success: true
}) as unknown as RedirectAction
),
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:
export const InitialForm: Story = {
};
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, getByLabelText over getByTestId
- Query by user-visible text with regex:
getByText(/welcome/i)
- Use case-insensitive matching:
/text/i
-
Await Async Operations
- Always
await user interactions
- Use
waitFor for dynamic content
- Use
findBy* queries for elements that appear asynchronously
-
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
-
Organize Tests with Steps
- Use
step() to group related assertions
- Makes test reports more readable
- Documents test flow
-
Handle Portals Correctly
- Use
canvasElement.parentElement for portal content
- Create new
within() context for portal
- Wait for portal content with
waitFor
-
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
userEvent.click(button);
expect(args.onSubmit).toHaveBeenCalled();
await userEvent.click(button);
await expect(args.onSubmit).toHaveBeenCalled();
-
Not using waitFor for dynamic content
const message = canvas.getByText(/success/i);
await expect(message).toBeVisible();
await waitFor(async () => {
const message = canvas.getByText(/success/i);
await expect(message).toBeVisible();
});
-
Using getBy* for elements that might not exist
const error = canvas.getByText(/error/i);
await expect(error).toBeNull();
const error = canvas.queryByText(/error/i);
await expect(error).toBeNull();
-
Not handling portals correctly
await userEvent.click(dropdownTrigger);
const option = canvas.getByRole("option", { name: /option/i });
await userEvent.click(dropdownTrigger);
const portal = within(canvasElement.parentElement as HTMLElement);
const option = portal.getByRole("option", { name: /option/i });
-
Not mocking functions properly
args: {
onSubmit: async () => {};
}
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
npm run storybook:dev
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, userEvent, 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>;
export const InitialState: Story = {
play: async ({ canvas, step }) => {
await step("Verify initial render", async () => {
const element = canvas.getByRole("button", { name: /click me/i });
await expect(element).toBeVisible();
});
}
};
export const Interaction: Story = {
args: {
onAction: fn()
},
play: async ({ canvas, args }) => {
const user = userEvent.setup();
const button = canvas.getByRole("button", { name: /click me/i });
await user.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?