Effective testing ensures reliable React applications. Here's a comprehensive approach.
Testing Pyramid#
/\
/ \ E2E Tests (few)
/----\ - Critical user flows
/ \ - Slow but realistic
/--------\ Integration Tests (some)
/ \ - Component interactions
/------------\ Unit Tests (many)
/ \ - Fast, isolated
/__________________\
Unit Testing Components#
1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import { Button } from './Button';
4
5describe('Button', () => {
6 it('renders with text', () => {
7 render(<Button>Click me</Button>);
8
9 expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
10 });
11
12 it('calls onClick when clicked', async () => {
13 const handleClick = jest.fn();
14 const user = userEvent.setup();
15
16 render(<Button onClick={handleClick}>Click me</Button>);
17
18 await user.click(screen.getByRole('button'));
19
20 expect(handleClick).toHaveBeenCalledTimes(1);
21 });
22
23 it('is disabled when loading', () => {
24 render(<Button loading>Submit</Button>);
25
26 expect(screen.getByRole('button')).toBeDisabled();
27 });
28
29 it('shows loading spinner', () => {
30 render(<Button loading>Submit</Button>);
31
32 expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
33 expect(screen.getByTestId('spinner')).toBeInTheDocument();
34 });
35});Testing Hooks#
1import { renderHook, act } from '@testing-library/react';
2import { useCounter } from './useCounter';
3
4describe('useCounter', () => {
5 it('initializes with default value', () => {
6 const { result } = renderHook(() => useCounter());
7
8 expect(result.current.count).toBe(0);
9 });
10
11 it('initializes with provided value', () => {
12 const { result } = renderHook(() => useCounter(10));
13
14 expect(result.current.count).toBe(10);
15 });
16
17 it('increments count', () => {
18 const { result } = renderHook(() => useCounter());
19
20 act(() => {
21 result.current.increment();
22 });
23
24 expect(result.current.count).toBe(1);
25 });
26
27 it('decrements count', () => {
28 const { result } = renderHook(() => useCounter(5));
29
30 act(() => {
31 result.current.decrement();
32 });
33
34 expect(result.current.count).toBe(4);
35 });
36
37 it('resets to initial value', () => {
38 const { result } = renderHook(() => useCounter(10));
39
40 act(() => {
41 result.current.increment();
42 result.current.increment();
43 result.current.reset();
44 });
45
46 expect(result.current.count).toBe(10);
47 });
48});Testing Async Operations#
1import { render, screen, waitFor } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import { UserProfile } from './UserProfile';
4
5// Mock fetch
6global.fetch = jest.fn();
7
8describe('UserProfile', () => {
9 beforeEach(() => {
10 (fetch as jest.Mock).mockClear();
11 });
12
13 it('shows loading state initially', () => {
14 (fetch as jest.Mock).mockImplementation(() => new Promise(() => {}));
15
16 render(<UserProfile userId="1" />);
17
18 expect(screen.getByText(/loading/i)).toBeInTheDocument();
19 });
20
21 it('displays user data after loading', async () => {
22 (fetch as jest.Mock).mockResolvedValue({
23 ok: true,
24 json: async () => ({ name: 'John Doe', email: 'john@example.com' }),
25 });
26
27 render(<UserProfile userId="1" />);
28
29 await waitFor(() => {
30 expect(screen.getByText('John Doe')).toBeInTheDocument();
31 });
32
33 expect(screen.getByText('john@example.com')).toBeInTheDocument();
34 });
35
36 it('displays error on fetch failure', async () => {
37 (fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
38
39 render(<UserProfile userId="1" />);
40
41 await waitFor(() => {
42 expect(screen.getByText(/error/i)).toBeInTheDocument();
43 });
44 });
45});Testing Forms#
1import { render, screen, waitFor } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import { ContactForm } from './ContactForm';
4
5describe('ContactForm', () => {
6 it('submits form with valid data', async () => {
7 const handleSubmit = jest.fn();
8 const user = userEvent.setup();
9
10 render(<ContactForm onSubmit={handleSubmit} />);
11
12 await user.type(screen.getByLabelText(/name/i), 'John Doe');
13 await user.type(screen.getByLabelText(/email/i), 'john@example.com');
14 await user.type(screen.getByLabelText(/message/i), 'Hello there!');
15
16 await user.click(screen.getByRole('button', { name: /submit/i }));
17
18 await waitFor(() => {
19 expect(handleSubmit).toHaveBeenCalledWith({
20 name: 'John Doe',
21 email: 'john@example.com',
22 message: 'Hello there!',
23 });
24 });
25 });
26
27 it('shows validation errors', async () => {
28 const user = userEvent.setup();
29
30 render(<ContactForm onSubmit={jest.fn()} />);
31
32 await user.click(screen.getByRole('button', { name: /submit/i }));
33
34 await waitFor(() => {
35 expect(screen.getByText(/name is required/i)).toBeInTheDocument();
36 expect(screen.getByText(/email is required/i)).toBeInTheDocument();
37 });
38 });
39
40 it('validates email format', async () => {
41 const user = userEvent.setup();
42
43 render(<ContactForm onSubmit={jest.fn()} />);
44
45 await user.type(screen.getByLabelText(/email/i), 'invalid-email');
46 await user.tab(); // Trigger blur
47
48 await waitFor(() => {
49 expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
50 });
51 });
52});Integration Testing#
1import { render, screen, waitFor } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4import { App } from './App';
5import { server } from './mocks/server';
6import { rest } from 'msw';
7
8// Setup MSW
9beforeAll(() => server.listen());
10afterEach(() => server.resetHandlers());
11afterAll(() => server.close());
12
13function renderApp() {
14 const queryClient = new QueryClient({
15 defaultOptions: {
16 queries: { retry: false },
17 },
18 });
19
20 return render(
21 <QueryClientProvider client={queryClient}>
22 <App />
23 </QueryClientProvider>
24 );
25}
26
27describe('App Integration', () => {
28 it('displays user list and allows viewing details', async () => {
29 const user = userEvent.setup();
30
31 renderApp();
32
33 // Wait for user list to load
34 await waitFor(() => {
35 expect(screen.getByText('John Doe')).toBeInTheDocument();
36 });
37
38 // Click on user
39 await user.click(screen.getByText('John Doe'));
40
41 // Verify details are shown
42 await waitFor(() => {
43 expect(screen.getByText('john@example.com')).toBeInTheDocument();
44 });
45 });
46
47 it('handles API errors gracefully', async () => {
48 server.use(
49 rest.get('/api/users', (req, res, ctx) => {
50 return res(ctx.status(500));
51 })
52 );
53
54 renderApp();
55
56 await waitFor(() => {
57 expect(screen.getByText(/error loading users/i)).toBeInTheDocument();
58 });
59
60 // Verify retry button exists
61 expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
62 });
63});Mock Service Worker#
1// mocks/handlers.ts
2import { rest } from 'msw';
3
4export const handlers = [
5 rest.get('/api/users', (req, res, ctx) => {
6 return res(
7 ctx.json([
8 { id: 1, name: 'John Doe', email: 'john@example.com' },
9 { id: 2, name: 'Jane Doe', email: 'jane@example.com' },
10 ])
11 );
12 }),
13
14 rest.get('/api/users/:id', (req, res, ctx) => {
15 const { id } = req.params;
16 return res(
17 ctx.json({
18 id: Number(id),
19 name: 'John Doe',
20 email: 'john@example.com',
21 })
22 );
23 }),
24
25 rest.post('/api/users', async (req, res, ctx) => {
26 const body = await req.json();
27 return res(
28 ctx.status(201),
29 ctx.json({ id: 3, ...body })
30 );
31 }),
32];
33
34// mocks/server.ts
35import { setupServer } from 'msw/node';
36import { handlers } from './handlers';
37
38export const server = setupServer(...handlers);Testing Context#
1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import { ThemeProvider, useTheme } from './ThemeContext';
4
5function TestComponent() {
6 const { theme, toggleTheme } = useTheme();
7 return (
8 <div>
9 <span>Theme: {theme}</span>
10 <button onClick={toggleTheme}>Toggle</button>
11 </div>
12 );
13}
14
15describe('ThemeContext', () => {
16 it('provides default theme', () => {
17 render(
18 <ThemeProvider>
19 <TestComponent />
20 </ThemeProvider>
21 );
22
23 expect(screen.getByText('Theme: light')).toBeInTheDocument();
24 });
25
26 it('toggles theme', async () => {
27 const user = userEvent.setup();
28
29 render(
30 <ThemeProvider>
31 <TestComponent />
32 </ThemeProvider>
33 );
34
35 await user.click(screen.getByRole('button'));
36
37 expect(screen.getByText('Theme: dark')).toBeInTheDocument();
38 });
39});Snapshot Testing#
1import { render } from '@testing-library/react';
2import { Card } from './Card';
3
4describe('Card', () => {
5 it('matches snapshot', () => {
6 const { container } = render(
7 <Card title="Test Card" description="Test description" />
8 );
9
10 expect(container).toMatchSnapshot();
11 });
12
13 it('matches inline snapshot', () => {
14 const { container } = render(<Card title="Simple" />);
15
16 expect(container).toMatchInlineSnapshot(`
17 <div>
18 <div class="card">
19 <h2>Simple</h2>
20 </div>
21 </div>
22 `);
23 });
24});Test Utilities#
1// test-utils.tsx
2import { render, RenderOptions } from '@testing-library/react';
3import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4import { ThemeProvider } from './ThemeContext';
5import { AuthProvider } from './AuthContext';
6
7interface WrapperProps {
8 children: React.ReactNode;
9}
10
11function AllProviders({ children }: WrapperProps) {
12 const queryClient = new QueryClient({
13 defaultOptions: {
14 queries: { retry: false },
15 },
16 });
17
18 return (
19 <QueryClientProvider client={queryClient}>
20 <AuthProvider>
21 <ThemeProvider>{children}</ThemeProvider>
22 </AuthProvider>
23 </QueryClientProvider>
24 );
25}
26
27function customRender(
28 ui: React.ReactElement,
29 options?: Omit<RenderOptions, 'wrapper'>
30) {
31 return render(ui, { wrapper: AllProviders, ...options });
32}
33
34export * from '@testing-library/react';
35export { customRender as render };Best Practices#
Guidelines:
✓ Test behavior, not implementation
✓ Use accessible queries (getByRole, getByLabelText)
✓ Avoid testing internal state
✓ Keep tests focused and readable
Performance:
✓ Use MSW for API mocking
✓ Run tests in parallel
✓ Reset state between tests
✓ Use beforeEach/afterEach properly
Coverage:
✓ Aim for meaningful coverage
✓ Test error states
✓ Test loading states
✓ Test edge cases
Conclusion#
Effective React testing combines unit tests for components and hooks, integration tests for feature flows, and E2E tests for critical paths. Use Testing Library's accessible queries, MSW for API mocking, and focus on testing user behavior rather than implementation details.