Testing Patterns Reference
Last Updated: November 23, 2025
This document covers Testing Library best practices, query selection, and interaction testing patterns for Storybook stories.
Table of Contents
- Testing Library Query Priority
- Query Types
- Play Functions
- Step Function
- Function Spying
- Mount Function
- Best Practices
Testing Library Query Priority
Use queries in this order of preference to ensure accessibility and maintainability:
1. getByRole (Best)
Tests accessibility and semantic meaning:
// ✅ Best - Tests accessibility
await canvas.getByRole('button', { name: /submit/i });
await canvas.getByRole('textbox', { name: /email/i });
await canvas.getByRole('heading', { name: /welcome/i });
await canvas.getByRole('link', { name: /sign up/i });
Common roles:
button- Buttonstextbox- Input fieldsheading- Headings (h1-h6)link- Linkscheckbox- Checkboxesradio- Radio buttonscombobox- Select dropdownsdialog- Modal dialogsalert- Alert messages
2. getByLabelText (Good)
Tests form accessibility:
// ✅ Good - Tests form labels
await canvas.getByLabelText('Email Address');
await canvas.getByLabelText(/password/i);
Best for:
- Form inputs with associated labels
- Ensuring proper label-input relationships
3. getByPlaceholderText (OK)
Tests visual content but less accessible:
// ⚠️ OK - Less accessible than label
await canvas.getByPlaceholderText('Enter your email');
Use when:
- No label exists (though adding one is better)
- Placeholder is the primary identifier
4. getByText (OK)
Tests visible text content:
// ⚠️ OK - Tests visible content
await canvas.getByText(/loading/i);
await canvas.getByText('Welcome back!');
Best for:
- Non-interactive text (paragraphs, spans)
- Error messages
- Status indicators
5. getByTestId (Last Resort)
Tests implementation details:
// ❌ Last resort - Implementation detail
await canvas.getByTestId('user-menu');
Only use when:
- No semantic or accessible query works
- Third-party components without proper roles
- Complex scenarios where other queries fail
Query Types
getBy* - Element Must Exist
Throws error if element not found:
// Element must exist immediately
const button = canvas.getByRole('button', { name: /submit/i });
await userEvent.click(button);
Use for:
- Elements that should always be present
- Synchronous operations
queryBy* - Element May Not Exist
Returns null if not found:
// Check if element exists
const errorMsg = canvas.queryByText(/error/i);
if (errorMsg) {
// Handle error state
}
// Assert element doesn't exist
expect(canvas.queryByRole('alert')).not.toBeInTheDocument();
Use for:
- Conditional rendering
- Asserting element absence
findBy* - Wait for Async Element
Waits for element to appear, then throws if not found:
// Wait for async element (up to 1000ms by default)
const message = await canvas.findByText(/success/i);
expect(message).toBeInTheDocument();
Use for:
- Elements that appear after async operations
- Loading states
- API responses
Configure timeout:
await canvas.findByText(/success/i, {}, { timeout: 3000 });
Play Functions
Play functions enable automated interaction testing.
Basic Structure
import { userEvent, within, expect } from '@storybook/test';
export const Interactive: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Interactions
await userEvent.type(canvas.getByLabelText(/email/i), 'user@example.com');
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
// Assertions
await expect(canvas.findByText(/success/i)).resolves.toBeInTheDocument();
},
};
User Interactions
// Type text
await userEvent.type(input, 'Hello world');
await userEvent.type(input, 'Hello{Enter}'); // Special keys
// Click
await userEvent.click(button);
await userEvent.dblClick(button);
// Select
await userEvent.selectOptions(select, 'option1');
await userEvent.selectOptions(select, ['option1', 'option2']); // Multiple
// Upload file
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
await userEvent.upload(input, file);
// Keyboard
await userEvent.keyboard('{Escape}');
await userEvent.keyboard('{Control>}A{/Control}'); // Ctrl+A
// Hover
await userEvent.hover(element);
await userEvent.unhover(element);
// Tab
await userEvent.tab(); // Move focus forward
await userEvent.tab({ shift: true }); // Move focus backward
Assertions
// Presence
expect(element).toBeInTheDocument();
expect(element).not.toBeInTheDocument();
// Visibility
expect(element).toBeVisible();
expect(element).not.toBeVisible();
// Text content
expect(element).toHaveTextContent('Hello');
expect(element).toHaveTextContent(/hello/i);
// Values
expect(input).toHaveValue('text');
expect(checkbox).toBeChecked();
expect(radio).not.toBeChecked();
// Attributes
expect(button).toBeDisabled();
expect(button).toBeEnabled();
expect(link).toHaveAttribute('href', '/page');
// Classes
expect(element).toHaveClass('active');
expect(element).not.toHaveClass('disabled');
// Accessibility
expect(button).toHaveAccessibleName('Submit');
expect(input).toHaveAccessibleDescription('Enter your email');
Step Function
Use steps to organize complex interactions and improve readability:
export const ComplexFlow: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('Fill out form', async () => {
await userEvent.type(
canvas.getByLabelText(/email/i),
'user@example.com'
);
await userEvent.type(
canvas.getByLabelText(/password/i),
'secure123'
);
});
await step('Submit form', async () => {
await userEvent.click(
canvas.getByRole('button', { name: /submit/i })
);
});
await step('Verify success', async () => {
await expect(
canvas.findByText(/welcome/i)
).resolves.toBeInTheDocument();
});
},
};
Benefits:
- Better test organization
- Clear step-by-step flow
- Easier debugging (shows which step failed)
- Improved readability in Storybook UI
Function Spying
Use fn() to spy on function calls and arguments:
import { fn } from '@storybook/test';
const meta = {
component: Form,
args: {
onSubmit: fn(), // Creates a spy function
},
} satisfies Meta<typeof Form>;
export const SubmittedForm: Story = {
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
// Fill and submit form
await userEvent.type(canvas.getByLabelText(/email/i), 'test@example.com');
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
// Verify function was called
await expect(args.onSubmit).toHaveBeenCalled();
await expect(args.onSubmit).toHaveBeenCalledTimes(1);
// Verify arguments
await expect(args.onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
});
// Verify partial match
await expect(args.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
email: expect.any(String),
})
);
},
};
Common matchers:
expect(fn).toHaveBeenCalled()
expect(fn).toHaveBeenCalledTimes(n)
expect(fn).toHaveBeenCalledWith(args)
expect(fn).toHaveBeenLastCalledWith(args)
expect(fn).toHaveBeenNthCalledWith(n, args)
Mount Function
Execute code before component renders:
export const WithPreparation: Story = {
async play({ mount, args }) {
// Prepare data before mount
const mockData = await fetchMockData();
// Mount with prepared data
const canvas = await mount(
<MyComponent {...args} data={mockData} />
);
// Interact after mount
await userEvent.click(
await canvas.findByRole('button')
);
},
};
Use cases:
- Async data preparation
- Dynamic component configuration
- Conditional rendering based on async state
Best Practices
1. Prefer Semantic Queries
// ✅ Good - Semantic, accessible
await canvas.getByRole('button', { name: /submit/i });
// ❌ Avoid - Test ID when not necessary
await canvas.getByTestId('submit-button');
2. Use Case-Insensitive Matching
// ✅ Good - Case-insensitive regex
await canvas.getByText(/submit/i);
await canvas.getByRole('button', { name: /submit/i });
// ❌ Avoid - Case-sensitive (brittle)
await canvas.getByText('Submit');
3. Wait for Async Changes
// ✅ Good - Wait for async state
await userEvent.click(button);
await expect(canvas.findByText(/success/i)).resolves.toBeInTheDocument();
// ❌ Avoid - Immediate check after async action
await userEvent.click(button);
expect(canvas.getByText(/success/i)).toBeInTheDocument(); // May fail
4. Test User-Visible Behavior
// ✅ Good - Tests user-visible outcome
await userEvent.click(button);
await expect(canvas.findByText(/saved/i)).resolves.toBeInTheDocument();
// ❌ Avoid - Tests implementation details
await userEvent.click(button);
expect(mockSetState).toHaveBeenCalledWith({ saved: true });
5. Use Clear Selectors
// ✅ Good - Specific, clear selector
await canvas.getByRole('button', { name: /submit form/i });
// ❌ Avoid - Vague selector
await canvas.getByRole('button'); // Which button?
6. Organize Complex Tests with Steps
// ✅ Good - Organized with steps
await step('User login', async () => { /* ... */ });
await step('Navigate to settings', async () => { /* ... */ });
await step('Update preferences', async () => { /* ... */ });
// ❌ Avoid - Long unorganized sequence
await userEvent.type(/* ... */);
await userEvent.click(/* ... */);
// ... 20 more lines
7. Handle Loading States
// ✅ Good - Wait for loading to complete
await userEvent.click(loadButton);
await canvas.findByText(/loading/i); // Wait for loading to appear
await expect(
canvas.findByText(/data loaded/i)
).resolves.toBeInTheDocument(); // Wait for completion
// ❌ Avoid - Don't wait for loading states
await userEvent.click(loadButton);
expect(canvas.getByText(/data loaded/i)).toBeInTheDocument(); // May fail
Common Patterns
Form Submission
export const FormSubmit: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('Fill form', async () => {
await userEvent.type(canvas.getByLabelText(/email/i), 'user@test.com');
await userEvent.type(canvas.getByLabelText(/password/i), 'pass123');
});
await step('Submit', async () => {
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
});
await step('Verify success', async () => {
await expect(canvas.findByText(/success/i)).resolves.toBeInTheDocument();
});
},
};
Error Handling
export const WithError: Story = {
parameters: {
msw: {
handlers: [
trpcMsw.endpoint.mutation(() => {
throw new Error('Server error');
}),
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
await expect(
canvas.findByRole('alert')
).resolves.toHaveTextContent(/error/i);
},
};
Navigation
export const Navigation: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('Open menu', async () => {
await userEvent.click(canvas.getByRole('button', { name: /menu/i }));
});
await step('Click settings link', async () => {
await userEvent.click(await canvas.findByRole('link', { name: /settings/i }));
});
},
};
Related Documentation
- Story Structure - Story file structure and configuration
- MSW Handlers - API mocking patterns
- Decorators - Story decorator patterns
- Helpers - Reusable story utilities
For task-specific implementation details, see the Storybook integration task files in docs/storybook-integration/.