Testing React Applications
Unit testing, component testing, and mocking with Jest and React Testing Library
Why Test React Apps?
Testing ensures your components work correctly, prevents regressions, and gives you confidence to refactor. The React ecosystem has excellent testing tools: Jest as the test runner and React Testing Library (RTL) for component testing.
React Testing Library encourages testing components the way users interact with them β by finding elements by role, label, or text rather than implementation details like class names or state.
π§ͺ The Testing Philosophy
"The more your tests resemble the way your software is used, the more confidence they can give you."
β Kent C. Dodds, creator of React Testing Library
Setup
Install the testing libraries (these come pre-configured with Create React App and most frameworks):
npm install --save-dev @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event
npm install --save-dev jest jest-environment-jsdom
Your First Component Test
Here's a simple component and its test:
// Greeting.jsx
export function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { Greeting } from './Greeting';
test('renders greeting with name', () => {
render(<Greeting name="Alice" />);
// Find the heading element
const heading = screen.getByRole('heading');
// Assert it has the right text
expect(heading).toHaveTextContent('Hello, Alice!');
});
test('renders with different name', () => {
render(<Greeting name="Bob" />);
expect(screen.getByText('Hello, Bob!')).toBeInTheDocument();
});
Testing User Interactions
Use userEvent to simulate real user behavior (clicks, typing, etc.):
// Counter.jsx
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={() => setCount(c => c - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
// Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
test('increments and decrements counter', async () => {
const user = userEvent.setup();
render(<Counter />);
// Initial state
expect(screen.getByText('Count: 0')).toBeInTheDocument();
// Click increment twice
await user.click(screen.getByText('Increment'));
await user.click(screen.getByText('Increment'));
expect(screen.getByText('Count: 2')).toBeInTheDocument();
// Click decrement
await user.click(screen.getByText('Decrement'));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
// Reset
await user.click(screen.getByText('Reset'));
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
Testing Forms
// LoginForm.jsx
export function LoginForm({ onSubmit }) {
return (
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
onSubmit({
email: formData.get('email'),
password: formData.get('password'),
});
}}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
<button type="submit">Log In</button>
</form>
);
}
// LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
test('submits form with email and password', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// Fill in the form β find inputs by their label
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'secret123');
// Submit
await user.click(screen.getByRole('button', { name: 'Log In' }));
// Assert onSubmit was called with correct data
expect(handleSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'secret123',
});
});
Mocking API Calls
Mock fetch or API functions to test components that load data:
// UserList.jsx
import { useState, useEffect } from 'react';
export function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => { setUsers(data); setLoading(false); })
.catch(err => { setError(err.message); setLoading(false); });
}, []);
if (loading) return <p>Loading users...</p>;
if (error) return <p role="alert">Error: {error}</p>;
return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
// UserList.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserList } from './UserList';
// Mock the global fetch
beforeEach(() => {
global.fetch = jest.fn();
});
test('renders users after loading', async () => {
fetch.mockResolvedValueOnce({
json: () => Promise.resolve([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]),
});
render(<UserList />);
// Loading state shows first
expect(screen.getByText('Loading users...')).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
});
test('shows error on fetch failure', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
render(<UserList />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Error: Network error');
});
});
Testing Custom Hooks
Use renderHook to test custom hooks in isolation:
// useCounter.js
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments and decrements', () => {
const { result } = renderHook(() => useCounter(5));
act(() => result.current.increment());
expect(result.current.count).toBe(6);
act(() => result.current.decrement());
expect(result.current.count).toBe(5);
act(() => result.current.reset());
expect(result.current.count).toBe(5);
});
Common Query Methods
| Method | Use Case | Fails if not found? |
|---|---|---|
getByRole | Best for buttons, links, headings | Yes (throws) |
getByLabelText | Best for form inputs | Yes |
getByText | Find by visible text | Yes |
getByTestId | Last resort β use data-testid | Yes |
queryByText | Assert something is NOT present | No (returns null) |
findByText | Wait for async element to appear | Yes (async) |
β Testing Best Practices
- β’ Query elements by role and label first β avoid test IDs when possible
- β’ Test behavior, not implementation β don't test internal state directly
- β’ Use
userEventoverfireEventfor more realistic interactions - β’ Use
waitFororfindByfor async operations - β’ Keep tests focused β one behavior per test
- β’ Mock at the boundary (API calls, not internal functions)
- β’ Use
screen.debug()to diagnose what the DOM looks like