Effective tests give confidence without slowing development. Here's how to test React applications.
Testing Philosophy#
1// Test behavior, not implementation
2// Bad: Testing implementation details
3test('button has onClick handler', () => {
4 const wrapper = shallow(<Button />);
5 expect(wrapper.instance().handleClick).toBeDefined();
6});
7
8// Good: Testing behavior
9test('calls onSubmit when clicked', async () => {
10 const handleSubmit = jest.fn();
11 render(<Button onSubmit={handleSubmit}>Submit</Button>);
12
13 await userEvent.click(screen.getByRole('button'));
14
15 expect(handleSubmit).toHaveBeenCalledTimes(1);
16});Component Testing#
1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3
4// Basic render test
5test('renders greeting', () => {
6 render(<Greeting name="Alice" />);
7
8 expect(screen.getByText(/hello, alice/i)).toBeInTheDocument();
9});
10
11// Testing interactions
12test('increments counter on click', async () => {
13 const user = userEvent.setup();
14 render(<Counter />);
15
16 const button = screen.getByRole('button', { name: /increment/i });
17 await user.click(button);
18
19 expect(screen.getByText('Count: 1')).toBeInTheDocument();
20});
21
22// Testing form submission
23test('submits form with user data', async () => {
24 const user = userEvent.setup();
25 const handleSubmit = jest.fn();
26
27 render(<LoginForm onSubmit={handleSubmit} />);
28
29 await user.type(screen.getByLabelText(/email/i), 'test@example.com');
30 await user.type(screen.getByLabelText(/password/i), 'password123');
31 await user.click(screen.getByRole('button', { name: /sign in/i }));
32
33 expect(handleSubmit).toHaveBeenCalledWith({
34 email: 'test@example.com',
35 password: 'password123',
36 });
37});
38
39// Testing conditional rendering
40test('shows error message on invalid input', async () => {
41 const user = userEvent.setup();
42 render(<EmailInput />);
43
44 const input = screen.getByLabelText(/email/i);
45 await user.type(input, 'invalid-email');
46 await user.tab(); // Blur to trigger validation
47
48 expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
49});Async Testing#
1import { render, screen, waitFor } from '@testing-library/react';
2
3// Testing loading states
4test('shows loading then data', async () => {
5 render(<UserProfile userId="1" />);
6
7 // Initially shows loading
8 expect(screen.getByText(/loading/i)).toBeInTheDocument();
9
10 // Wait for data
11 await waitFor(() => {
12 expect(screen.getByText('Alice')).toBeInTheDocument();
13 });
14
15 // Loading should be gone
16 expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
17});
18
19// Testing with findBy (waits automatically)
20test('loads and displays user', async () => {
21 render(<UserProfile userId="1" />);
22
23 const userName = await screen.findByText('Alice');
24 expect(userName).toBeInTheDocument();
25});
26
27// Testing error states
28test('shows error on fetch failure', async () => {
29 server.use(
30 rest.get('/api/user', (req, res, ctx) => {
31 return res(ctx.status(500));
32 })
33 );
34
35 render(<UserProfile userId="1" />);
36
37 await waitFor(() => {
38 expect(screen.getByText(/error loading user/i)).toBeInTheDocument();
39 });
40});Mocking#
1// Mock modules
2jest.mock('./api', () => ({
3 fetchUser: jest.fn(),
4}));
5
6import { fetchUser } from './api';
7
8beforeEach(() => {
9 (fetchUser as jest.Mock).mockClear();
10});
11
12test('fetches user on mount', async () => {
13 (fetchUser as jest.Mock).mockResolvedValue({ name: 'Alice' });
14
15 render(<UserProfile userId="1" />);
16
17 await waitFor(() => {
18 expect(fetchUser).toHaveBeenCalledWith('1');
19 });
20});
21
22// Mock child components
23jest.mock('./HeavyComponent', () => ({
24 HeavyComponent: () => <div data-testid="mock-heavy">Mocked</div>,
25}));
26
27// Mock hooks
28jest.mock('./useAuth', () => ({
29 useAuth: () => ({
30 user: { id: '1', name: 'Alice' },
31 isAuthenticated: true,
32 }),
33}));
34
35// Mock with implementation
36const mockNavigate = jest.fn();
37jest.mock('react-router-dom', () => ({
38 ...jest.requireActual('react-router-dom'),
39 useNavigate: () => mockNavigate,
40}));
41
42test('navigates on success', async () => {
43 render(<LoginForm />);
44 // ... submit form
45
46 await waitFor(() => {
47 expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
48 });
49});Testing Hooks#
1import { renderHook, act } from '@testing-library/react';
2
3// Basic hook test
4test('useCounter increments', () => {
5 const { result } = renderHook(() => useCounter());
6
7 act(() => {
8 result.current.increment();
9 });
10
11 expect(result.current.count).toBe(1);
12});
13
14// Hook with dependencies
15test('useCounter starts with initial value', () => {
16 const { result } = renderHook(() => useCounter(10));
17
18 expect(result.current.count).toBe(10);
19});
20
21// Hook with changing props
22test('useCounter resets on prop change', () => {
23 const { result, rerender } = renderHook(
24 ({ initial }) => useCounter(initial),
25 { initialProps: { initial: 0 } }
26 );
27
28 act(() => {
29 result.current.increment();
30 });
31
32 expect(result.current.count).toBe(1);
33
34 rerender({ initial: 10 });
35
36 expect(result.current.count).toBe(10);
37});
38
39// Async hook
40test('useFetch returns data', async () => {
41 const { result } = renderHook(() => useFetch('/api/data'));
42
43 expect(result.current.loading).toBe(true);
44
45 await waitFor(() => {
46 expect(result.current.loading).toBe(false);
47 });
48
49 expect(result.current.data).toEqual({ id: 1 });
50});
51
52// Hook with wrapper (for context)
53test('useUser returns user from context', () => {
54 const wrapper = ({ children }) => (
55 <UserProvider value={{ name: 'Alice' }}>
56 {children}
57 </UserProvider>
58 );
59
60 const { result } = renderHook(() => useUser(), { wrapper });
61
62 expect(result.current.name).toBe('Alice');
63});Testing with Context#
1// Create test wrapper
2function renderWithProviders(
3 ui: React.ReactElement,
4 {
5 initialState = {},
6 ...renderOptions
7 } = {}
8) {
9 function Wrapper({ children }: { children: React.ReactNode }) {
10 return (
11 <ThemeProvider>
12 <AuthProvider initialState={initialState}>
13 <Router>
14 {children}
15 </Router>
16 </AuthProvider>
17 </ThemeProvider>
18 );
19 }
20
21 return render(ui, { wrapper: Wrapper, ...renderOptions });
22}
23
24// Use in tests
25test('shows user name', () => {
26 renderWithProviders(<UserMenu />, {
27 initialState: { user: { name: 'Alice' } },
28 });
29
30 expect(screen.getByText('Alice')).toBeInTheDocument();
31});Integration Tests#
1// Test full user flow
2test('complete checkout flow', async () => {
3 const user = userEvent.setup();
4
5 renderWithProviders(<App />);
6
7 // Add item to cart
8 await user.click(screen.getByRole('button', { name: /add to cart/i }));
9
10 // Go to cart
11 await user.click(screen.getByRole('link', { name: /cart/i }));
12
13 // Proceed to checkout
14 await user.click(screen.getByRole('button', { name: /checkout/i }));
15
16 // Fill shipping info
17 await user.type(screen.getByLabelText(/address/i), '123 Main St');
18 await user.click(screen.getByRole('button', { name: /continue/i }));
19
20 // Fill payment info
21 await user.type(screen.getByLabelText(/card number/i), '4242424242424242');
22 await user.click(screen.getByRole('button', { name: /place order/i }));
23
24 // Verify success
25 await waitFor(() => {
26 expect(screen.getByText(/order confirmed/i)).toBeInTheDocument();
27 });
28});MSW for API Mocking#
1import { rest } from 'msw';
2import { setupServer } from 'msw/node';
3
4// Setup server
5const server = setupServer(
6 rest.get('/api/users', (req, res, ctx) => {
7 return res(ctx.json([
8 { id: 1, name: 'Alice' },
9 { id: 2, name: 'Bob' },
10 ]));
11 }),
12
13 rest.post('/api/users', async (req, res, ctx) => {
14 const { name } = await req.json();
15 return res(ctx.json({ id: 3, name }));
16 })
17);
18
19beforeAll(() => server.listen());
20afterEach(() => server.resetHandlers());
21afterAll(() => server.close());
22
23// Use in tests
24test('creates new user', async () => {
25 const user = userEvent.setup();
26
27 render(<CreateUserForm />);
28
29 await user.type(screen.getByLabelText(/name/i), 'Charlie');
30 await user.click(screen.getByRole('button', { name: /create/i }));
31
32 await waitFor(() => {
33 expect(screen.getByText(/user created/i)).toBeInTheDocument();
34 });
35});
36
37// Override handlers in specific tests
38test('handles server error', async () => {
39 server.use(
40 rest.post('/api/users', (req, res, ctx) => {
41 return res(ctx.status(500), ctx.json({ error: 'Server error' }));
42 })
43 );
44
45 render(<CreateUserForm />);
46 // ... test error handling
47});Snapshot Testing#
1// Use sparingly, for stable UI
2test('renders correctly', () => {
3 const { container } = render(<Icon name="star" />);
4 expect(container.firstChild).toMatchSnapshot();
5});
6
7// Inline snapshots
8test('formats date correctly', () => {
9 expect(formatDate(new Date('2024-01-15'))).toMatchInlineSnapshot(
10 `"January 15, 2024"`
11 );
12});Accessibility Testing#
1import { axe, toHaveNoViolations } from 'jest-axe';
2
3expect.extend(toHaveNoViolations);
4
5test('has no accessibility violations', async () => {
6 const { container } = render(<Form />);
7 const results = await axe(container);
8
9 expect(results).toHaveNoViolations();
10});
11
12// Test specific accessibility
13test('form inputs have labels', () => {
14 render(<LoginForm />);
15
16 expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
17 expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
18});
19
20test('buttons are keyboard accessible', async () => {
21 const user = userEvent.setup();
22 const handleClick = jest.fn();
23
24 render(<Button onClick={handleClick}>Click me</Button>);
25
26 const button = screen.getByRole('button');
27 button.focus();
28
29 await user.keyboard('{Enter}');
30
31 expect(handleClick).toHaveBeenCalled();
32});Best Practices#
Principles:
✓ Test behavior, not implementation
✓ Write tests from user perspective
✓ Use semantic queries (getByRole, getByLabelText)
✓ Avoid testing internal state
Organization:
✓ One assertion per test when possible
✓ Use descriptive test names
✓ Group related tests with describe
✓ Keep tests independent
Performance:
✓ Mock expensive operations
✓ Use beforeEach for setup
✓ Clean up after tests
✓ Run tests in parallel
Coverage:
✓ Focus on critical paths
✓ Test error states
✓ Test edge cases
✓ Don't chase 100% coverage
Conclusion#
Effective React testing focuses on user behavior rather than implementation details. Use Testing Library's semantic queries, mock external dependencies with MSW, and test hooks with renderHook. Write integration tests for critical flows and keep tests maintainable by avoiding implementation details.