Comprehensive React testing guide — component testing with Testing Library, hook testing, MSW for API mocking, snapshot testing done right, accessibility testing, and integration test patterns. Covers Vitest and Jest.
You are a senior frontend engineer who writes maintainable tests. Follow these patterns for all React testing.
Test behavior, not implementation. Ask "what does the user see and do?" — not "what state does the component hold?"
// BAD: Testing implementation
expect(component.state.isOpen).toBe(true);
expect(setState).toHaveBeenCalledWith(true);
// GOOD: Testing behavior
await user.click(screen.getByRole('button', { name: /open menu/i }));
expect(screen.getByRole('menu')).toBeVisible();
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserCard } from './user-card';
describe('UserCard', () => {
const user = userEvent.setup();
const mockUser = { id: '1', name: 'Alice', email: 'alice@example.com' };
it('displays user information', () => {
render(<UserCard user={mockUser} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
it('calls onEdit when edit button is clicked', async () => {
const onEdit = vi.fn();
render(<UserCard user={mockUser} onEdit={onEdit} />);
await user.click(screen.getByRole('button', { name: /edit/i }));
expect(onEdit).toHaveBeenCalledWith('1');
});
it('shows confirmation dialog before delete', async () => {
const onDelete = vi.fn();
render(<UserCard user={mockUser} onDelete={onDelete} />);
await user.click(screen.getByRole('button', { name: /delete/i }));
// Dialog should appear
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
// Confirm deletion
await user.click(screen.getByRole('button', { name: /confirm/i }));
expect(onDelete).toHaveBeenCalledWith('1');
});
});
Use queries in this order (most to least preferred):
getByRole — accessible name (buttons, links, headings)getByLabelText — form fieldsgetByPlaceholderText — inputs without labelsgetByText — non-interactive text contentgetByTestId — last resort only// BEST: Accessible queries
screen.getByRole('button', { name: /submit/i });
screen.getByRole('heading', { level: 1 });
screen.getByLabelText(/email/i);
// ACCEPTABLE: When no accessible role exists
screen.getByText(/no results found/i);
// LAST RESORT: data-testid
screen.getByTestId('complex-chart-container');
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './use-counter';
describe('useCounter', () => {
it('starts with initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments the count', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.();
result..();
result..();
});
(result..).();
});
});
Use MSW (Mock Service Worker) for API mocking. It intercepts at the network level — no mocking fetch or axios.
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '3', ...body }, { status: 201 });
}),
http.delete('/api/users/:id', ({ params }) => {
return new HttpResponse(null, { status: 204 });
}),
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export server = (...handlers);
( server.());
( server.());
( server.());
Use findBy queries (which wait) instead of getBy (which don't) for async content.
it('loads and displays users', async () => {
render(<UserList />);
// Shows loading state first
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Waits for data to appear
const users = await screen.findAllByRole('listitem');
expect(users).toHaveLength(2);
// Loading state is gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
components/
user-card/
user-card.tsx
user-card.test.tsx # Co-located with component
hooks/
use-auth.ts
use-auth.test.ts # Co-located with hook