Testing Library Core Knowledge
Deep Knowledge: Use mcp__documentation__fetch_docs with technology: testing-library for comprehensive documentation.
When NOT to Use This Skill
-
E2E Testing - Use playwright or cypress for full browser automation
-
Unit Testing - Use vitest or jest for isolated function tests
-
API Testing - Use framework-specific HTTP testing tools
-
Performance Testing - Use Lighthouse or dedicated performance tools
-
Non-UI Logic - Test pure functions with unit tests instead
Basic Test
import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { UserForm } from './UserForm';
test('submits form with user data', async () => { const user = userEvent.setup(); const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/name/i), 'John'); await user.type(screen.getByLabelText(/email/i), 'john@example.com'); await user.click(screen.getByRole('button', { name: /submit/i }));
expect(onSubmit).toHaveBeenCalledWith({ name: 'John', email: 'john@example.com', }); });
Queries (Priority Order)
// 1. Accessible (preferred) screen.getByRole('button', { name: /submit/i }); screen.getByLabelText(/email/i); screen.getByPlaceholderText(/search/i); screen.getByText(/welcome/i); screen.getByAltText(/logo/i); screen.getByTitle(/close/i);
// 2. Test IDs (fallback) screen.getByTestId('submit-button');
// Query variants getBy... // Throws if not found queryBy... // Returns null if not found findBy... // Async, waits for element getAllBy... // Returns array
User Events
const user = userEvent.setup();
await user.click(element); await user.dblClick(element); await user.type(input, 'text'); await user.clear(input); await user.selectOptions(select, 'option1'); await user.keyboard('{Enter}'); await user.hover(element); await user.tab();
Async Utilities
import { waitFor, waitForElementToBeRemoved } from '@testing-library/react';
// Wait for condition await waitFor(() => { expect(screen.getByText(/success/i)).toBeInTheDocument(); });
// Wait for element removal await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
// findBy is shorthand for waitFor + getBy const element = await screen.findByText(/loaded/i);
Custom Render
// test-utils.tsx function renderWithProviders(ui: React.ReactElement) { return render( <QueryClientProvider client={queryClient}> <ThemeProvider> {ui} </ThemeProvider> </QueryClientProvider> ); }
export * from '@testing-library/react'; export { renderWithProviders as render };
Production Readiness
Test Organization
// test-utils.tsx - Centralized test setup import { render, RenderOptions } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, });
interface WrapperProps { children: React.ReactNode; }
function AllTheProviders({ children }: WrapperProps) { const queryClient = createTestQueryClient(); return ( <QueryClientProvider client={queryClient}> <ThemeProvider> <RouterProvider> {children} </RouterProvider> </ThemeProvider> </QueryClientProvider> ); }
const customRender = ( ui: React.ReactElement, options?: Omit<RenderOptions, 'wrapper'> ) => ({ user: userEvent.setup(), ...render(ui, { wrapper: AllTheProviders, ...options }), });
export * from '@testing-library/react'; export { customRender as render };
Accessibility Testing
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('form should be accessible', async () => { const { container } = render(<LoginForm />); const results = await axe(container); expect(results).toHaveNoViolations(); });
// Test keyboard navigation test('should be keyboard navigable', async () => { const { user } = render(<Navigation />);
await user.tab(); expect(screen.getByRole('link', { name: /home/i })).toHaveFocus();
await user.tab(); expect(screen.getByRole('link', { name: /about/i })).toHaveFocus(); });
MSW Integration
// mocks/handlers.ts import { http, HttpResponse } from 'msw';
export const handlers = [ http.get('/api/users', () => { return HttpResponse.json([ { id: 1, name: 'John' }, ]); }), http.post('/api/users', async ({ request }) => { const body = await request.json(); return HttpResponse.json({ id: 2, ...body }, { status: 201 }); }), ];
// mocks/server.ts import { setupServer } from 'msw/node'; import { handlers } from './handlers';
export const server = setupServer(...handlers);
// setup.ts beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => server.resetHandlers()); afterAll(() => server.close());
// In tests test('displays users from API', async () => { render(<UserList />);
expect(await screen.findByText('John')).toBeInTheDocument(); });
Testing Patterns
// Test loading states test('shows loading spinner while fetching', async () => { render(<UserProfile userId="1" />);
expect(screen.getByRole('progressbar')).toBeInTheDocument(); await waitForElementToBeRemoved(() => screen.queryByRole('progressbar')); expect(screen.getByText('John')).toBeInTheDocument(); });
// Test error states test('shows error message on failure', async () => { server.use( http.get('/api/users/:id', () => { return HttpResponse.json({ error: 'Not found' }, { status: 404 }); }) );
render(<UserProfile userId="999" />);
expect(await screen.findByRole('alert')).toHaveTextContent('Not found'); });
// Test form validation test('shows validation errors', async () => { const { user } = render(<SignupForm />);
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument(); });
Monitoring Metrics
Metric Target
Component test coverage
80%
Accessibility violations 0
Test execution time < 30s
Flaky test rate 0%
Checklist
-
Custom render with providers
-
userEvent.setup() for interactions
-
Accessible queries (getByRole, getByLabelText)
-
Accessibility testing with jest-axe
-
MSW for API mocking
-
Loading/error state tests
-
Keyboard navigation tests
-
Form validation tests
-
No implementation details tested
-
Async operations properly awaited
Anti-Patterns
Anti-Pattern Why It's Bad Solution
Using container.querySelector() Tests implementation, not behavior Use screen.getByRole, getByLabelText
Testing state directly Brittle, coupled to implementation Test rendered output, user interactions
Using fireEvent over userEvent Less realistic user interactions Use userEvent for better simulation
Not waiting for async updates Flaky tests, false failures Use findBy*, waitFor, or act()
Testing CSS classes Coupled to styling Test visual behavior or aria attributes
Multiple assertions without findBy Race conditions Use findBy for elements that appear async
Not using data-testid sparingly Defeats accessibility purpose Prefer getByRole, use testid as fallback
Quick Troubleshooting
Problem Likely Cause Solution
"Unable to find element" Wrong query or element not rendered Use screen.debug(), check query
"Not wrapped in act(...)" State update not awaited Use findBy* or waitFor
"Unable to find accessible element" Missing label or role Add aria-label, role, or label element
Test timeout Async operation not resolving Check findBy timeout, verify API mock
"Multiple elements found" Non-specific query Use more specific query or { name } option
fireEvent doesn't trigger handler Wrong event or React version Use userEvent instead of fireEvent
Reference Documentation
-
Queries
-
User Events