React Testing Guide
Comprehensive guide to testing React applications with Jest, React Testing Library, and modern testing practices.
Overview
Testing React applications ensures your components work correctly, handle user interactions properly, and maintain expected behavior as your app evolves.
Testing Philosophy
React Testing Library follows these principles:
- Test your app the way users interact with it
- Avoid testing implementation details
- Focus on behavior, not internals
- Write maintainable, confidence-inspiring tests
Setup
Create React App (Built-in)
Create React App includes Jest and React Testing Library out of the box:
npx create-react-app my-app
cd my-app
npm test
Manual Setup
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest
Configuration
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js'
},
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/index.js',
'!src/reportWebVitals.js'
]
};
// src/setupTests.js
import '@testing-library/jest-dom';
Basic Component Testing
Simple Functional Component
// Button.jsx
export function Button({ onClick, children, disabled }) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
// Button.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
test('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('calls onClick when clicked', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByText('Click me')).toBeDisabled();
});
});
Testing with Props
// Greeting.jsx
export function Greeting({ name, isLoggedIn }) {
return (
<div>
{isLoggedIn ? (
<h1>Welcome back, {name}!</h1>
) : (
<h1>Please sign in</h1>
)}
</div>
);
}
// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';
describe('Greeting', () => {
test('shows welcome message when logged in', () => {
render(<Greeting name="John" isLoggedIn={true} />);
expect(screen.getByText('Welcome back, John!')).toBeInTheDocument();
});
test('shows sign in message when not logged in', () => {
render(<Greeting name="John" isLoggedIn={false} />);
expect(screen.getByText('Please sign in')).toBeInTheDocument();
});
});
Testing State and Events
// Counter.jsx
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 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';
describe('Counter', () => {
test('starts at zero', () => {
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
test('increments count', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByText('Increment'));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
test('decrements count', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByText('Increment'));
await user.click(screen.getByText('Decrement'));
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
test('resets count', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByText('Increment'));
await user.click(screen.getByText('Increment'));
await user.click(screen.getByText('Reset'));
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
});
Testing Forms
// LoginForm.jsx
import { useState } from 'react';
export function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="password">Password:</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<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';
describe('LoginForm', () => {
test('submits form with email and password', async () => {
const handleSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
// Fill in form
await user.type(screen.getByLabelText('Email:'), 'john@example.com');
await user.type(screen.getByLabelText('Password:'), 'password123');
// Submit
await user.click(screen.getByText('Log In'));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'john@example.com',
password: 'password123'
});
});
test('updates input values as user types', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
const emailInput = screen.getByLabelText('Email:');
await user.type(emailInput, 'test@example.com');
expect(emailInput).toHaveValue('test@example.com');
});
});
Testing Hooks
Custom Hook
// useCounter.js
import { useState } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('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 count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Testing Async Code
// UserProfile.jsx
import { useState, useEffect } from 'react';
export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
// Mock fetch
global.fetch = jest.fn();
describe('UserProfile', () => {
beforeEach(() => {
fetch.mockClear();
});
test('displays loading state', () => {
fetch.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('displays user data when loaded', async () => {
const mockUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com'
};
fetch.mockResolvedValueOnce({
json: async () => mockUser
});
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
test('displays error message on fetch failure', async () => {
fetch.mockRejectedValueOnce(new Error('Failed to fetch'));
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('Error: Failed to fetch')).toBeInTheDocument();
});
});
});
Testing Context
// ThemeContext.js
import { createContext, useState, useContext } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// ThemedButton.jsx
import { useTheme } from './ThemeContext';
export function ThemedButton() {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
style={{
background: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#000' : '#fff'
}}
>
Current theme: {theme}
</button>
);
}
// ThemedButton.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from './ThemeContext';
import { ThemedButton } from './ThemedButton';
describe('ThemedButton', () => {
test('displays current theme', () => {
render(
<ThemeProvider>
<ThemedButton />
</ThemeProvider>
);
expect(screen.getByText('Current theme: light')).toBeInTheDocument();
});
test('toggles theme on click', async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<ThemedButton />
</ThemeProvider>
);
await user.click(screen.getByText('Current theme: light'));
expect(screen.getByText('Current theme: dark')).toBeInTheDocument();
});
});
Testing Redux
// userSlice.js
import { createSlice } from '@reduxjs/toolkit';
const userSlice = createSlice({
name: 'user',
initialState: {
name: '',
isLoggedIn: false
},
reducers: {
login: (state, action) => {
state.name = action.payload;
state.isLoggedIn = true;
},
logout: (state) => {
state.name = '';
state.isLoggedIn = false;
}
}
});
export const { login, logout } = userSlice.actions;
export default userSlice.reducer;
// UserDisplay.jsx
import { useSelector, useDispatch } from 'react-redux';
import { logout } from './userSlice';
export function UserDisplay() {
const { name, isLoggedIn } = useSelector(state => state.user);
const dispatch = useDispatch();
if (!isLoggedIn) {
return <div>Not logged in</div>;
}
return (
<div>
<p>Welcome, {name}!</p>
<button onClick={() => dispatch(logout())}>Logout</button>
</div>
);
}
// UserDisplay.test.jsx
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import userEvent from '@testing-library/user-event';
import userReducer from './userSlice';
import { UserDisplay } from './UserDisplay';
function renderWithRedux(
component,
{ initialState, store = configureStore({
reducer: { user: userReducer },
preloadedState: initialState
})} = {}
) {
return {
...render(<Provider store={store}>{component}</Provider>),
store
};
}
describe('UserDisplay', () => {
test('shows not logged in message when user is not authenticated', () => {
renderWithRedux(<UserDisplay />);
expect(screen.getByText('Not logged in')).toBeInTheDocument();
});
test('shows user name when logged in', () => {
renderWithRedux(<UserDisplay />, {
initialState: {
user: { name: 'John', isLoggedIn: true }
}
});
expect(screen.getByText('Welcome, John!')).toBeInTheDocument();
});
test('logs out user on button click', async () => {
const user = userEvent.setup();
const { store } = renderWithRedux(<UserDisplay />, {
initialState: {
user: { name: 'John', isLoggedIn: true }
}
});
await user.click(screen.getByText('Logout'));
expect(store.getState().user.isLoggedIn).toBe(false);
});
});
Testing Router
// Navigation.jsx
import { Link, useLocation } from 'react-router-dom';
export function Navigation() {
const location = useLocation();
return (
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/contact">Contact</Link>
<p>Current path: {location.pathname}</p>
</nav>
);
}
// Navigation.test.jsx
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import { Navigation } from './Navigation';
function renderWithRouter(component, { route = '/' } = {}) {
return render(
<MemoryRouter initialEntries={[route]}>
{component}
</MemoryRouter>
);
}
describe('Navigation', () => {
test('renders all navigation links', () => {
renderWithRouter(<Navigation />);
expect(screen.getByText('Home')).toBeInTheDocument();
expect(screen.getByText('About')).toBeInTheDocument();
expect(screen.getByText('Contact')).toBeInTheDocument();
});
test('displays current path', () => {
renderWithRouter(<Navigation />, { route: '/about' });
expect(screen.getByText('Current path: /about')).toBeInTheDocument();
});
});
Snapshot Testing
// Card.jsx
export function Card({ title, description, imageUrl }) {
return (
<div className="card">
{imageUrl && <img src={imageUrl} alt={title} />}
<h2>{title}</h2>
<p>{description}</p>
</div>
);
}
// Card.test.jsx
import { render } from '@testing-library/react';
import { Card } from './Card';
describe('Card', () => {
test('matches snapshot', () => {
const { container } = render(
<Card
title="Test Card"
description="This is a test card"
imageUrl="/test-image.jpg"
/>
);
expect(container).toMatchSnapshot();
});
test('matches snapshot without image', () => {
const { container } = render(
<Card
title="Test Card"
description="This is a test card"
/>
);
expect(container).toMatchSnapshot();
});
});
Best Practices
1. Query Priority
// ✅ Good - Accessible to everyone
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText('Email');
// ⚠️ Okay - But not accessible to screen readers
screen.getByPlaceholderText('Enter email');
screen.getByText('Submit');
// ❌ Bad - Implementation details
screen.getByTestId('submit-button');
2. Avoid Implementation Details
// ❌ Bad - Testing implementation
test('sets state correctly', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0); // Testing internal state
});
// ✅ Good - Testing behavior
test('displays correct count', () => {
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
3. Use User Events
// ❌ Bad - fireEvent
import { fireEvent } from '@testing-library/react';
fireEvent.click(button);
// ✅ Good - userEvent (simulates real user interaction)
import userEvent from '@testing-library/user-event';
const user = userEvent.setup();
await user.click(button);
4. Test Accessibility
test('form is accessible', () => {
render(<LoginForm />);
// Check for proper labels
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
// Check for button with accessible name
expect(screen.getByRole('button', { name: /log in/i })).toBeInTheDocument();
});
Common Testing Patterns
Test Helper Functions
// testUtils.js
import { render } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { Provider } from 'react-redux';
export function renderWithProviders(
component,
{ theme = 'light', store, ...options } = {}
) {
return render(
<Provider store={store}>
<ThemeProvider initialTheme={theme}>
{component}
</ThemeProvider>
</Provider>,
options
);
}
Mock Service Worker (MSW)
// mocks/handlers.js
import { rest } from 'msw';
export const handlers = [
rest.get('/api/users/:userId', (req, res, ctx) => {
return res(
ctx.json({
id: req.params.userId,
name: 'John Doe',
email: 'john@example.com'
})
);
})
];
// setupTests.js
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
export const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Resources
- React Testing Library Documentation
- Jest Documentation
- Testing Library Queries Cheatsheet
- Common Mistakes with React Testing Library
- MSW (Mock Service Worker)
Next Steps
- Testing Fundamentals - Core testing concepts
- Mocking and Test Doubles - Advanced mocking techniques
- TDD Guide - Test-Driven Development