Testing Basics
Testing Basics in React
Testing ensures your React components work correctly and helps prevent bugs. This tutorial covers the fundamentals of testing React components using Jest and React Testing Library.
Testing Philosophy
React Testing Library follows the principle: "The more your tests resemble the way your software is used, the more confidence they can give you."
Instead of testing implementation details, focus on:
- What users see and interact with
- How components behave from a user's perspective
- Accessibility and semantic HTML
Setup
Most React projects come with Jest and React Testing Library pre-configured. If you need to set them up:
npm install --save-dev @testing-library/react @testing-library/jest-dom
// src/setupTests.js
import '@testing-library/jest-dom';
Basic Component Testing
Simple Component Test
// Button.js
function Button({ children, onClick, disabled = false }) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
export default Button;
// Button.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button', () => {
test('renders button text', () => {
render(<Button>Click me</Button>);
const button = screen.getByText('Click me');
expect(button).toBeInTheDocument();
});
test('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByText('Click me');
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
const button = screen.getByText('Click me');
expect(button).toBeDisabled();
});
});
Testing State Changes
// Counter.js
import { useState } from 'react';
function Counter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<span data-testid="count">{count}</span>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<button onClick={() => setCount(count - 1)}>
Decrement
</button>
<button onClick={() => setCount(0)}>
Reset
</button>
</div>
);
}
export default Counter;
// Counter.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
describe('Counter', () => {
test('displays initial count', () => {
render(<Counter initialCount={5} />);
expect(screen.getByTestId('count')).toHaveTextContent('5');
});
test('increments count when increment button is clicked', () => {
render(<Counter />);
const incrementButton = screen.getByText('Increment');
fireEvent.click(incrementButton);
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
test('decrements count when decrement button is clicked', () => {
render(<Counter initialCount={5} />);
const decrementButton = screen.getByText('Decrement');
fireEvent.click(decrementButton);
expect(screen.getByTestId('count')).toHaveTextContent('4');
});
test('resets count to zero when reset button is clicked', () => {
render(<Counter initialCount={10} />);
const resetButton = screen.getByText('Reset');
fireEvent.click(resetButton);
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
});
Queries
Finding Elements
// UserProfile.js
function UserProfile({ user }) {
if (!user) {
return <div>No user found</div>;
}
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<img src={user.avatar} alt={`${user.name}'s avatar`} />
<button aria-label="Edit profile">Edit</button>
</div>
);
}
// UserProfile.test.js
import { render, screen } from '@testing-library/react';
import UserProfile from './UserProfile';
const mockUser = {
name: 'John Doe',
email: '[email protected]',
avatar: 'https://example.com/avatar.jpg'
};
describe('UserProfile', () => {
test('displays user information', () => {
render(<UserProfile user={mockUser} />);
// By text
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('[email protected]')).toBeInTheDocument();
// By role
expect(screen.getByRole('heading', { name: 'John Doe' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Edit profile' })).toBeInTheDocument();
// By alt text
expect(screen.getByAltText("John Doe's avatar")).toBeInTheDocument();
});
test('shows no user message when user is null', () => {
render(<UserProfile user={null} />);
expect(screen.getByText('No user found')).toBeInTheDocument();
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
});
Query Priority Order
// Recommended query priority:
describe('Query examples', () => {
test('query priority examples', () => {
render(
<div>
<label htmlFor="username">Username</label>
<input
id="username"
placeholder="Enter username"
data-testid="username-input"
/>
<button type="submit">Submit</button>
</div>
);
// 1. Accessible to everyone (preferred)
screen.getByRole('textbox', { name: /username/i });
screen.getByLabelText(/username/i);
// 2. Semantic queries
screen.getByPlaceholderText(/enter username/i);
screen.getByText(/submit/i);
// 3. Test IDs (last resort)
screen.getByTestId('username-input');
});
});
Form Testing
Input Handling
// LoginForm.js
import { useState } from 'react';
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
const handleSubmit = (e) => {
e.preventDefault();
const newErrors = {};
if (!email) newErrors.email = 'Email is required';
if (!password) newErrors.password = 'Password is required';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
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)}
/>
{errors.email && <span role="alert">{errors.email}</span>}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{errors.password && <span role="alert">{errors.password}</span>}
</div>
<button type="submit">Login</button>
</form>
);
}
export default LoginForm;
// LoginForm.test.js
import { render, screen, fireEvent } 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 user = userEvent.setup();
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
// Fill out form
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
// Submit form
await user.click(screen.getByRole('button', { name: /login/i }));
expect(mockSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'password123'
});
});
test('shows validation errors for empty fields', async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
// Submit form without filling fields
await user.click(screen.getByRole('button', { name: /login/i }));
expect(screen.getByText('Email is required')).toBeInTheDocument();
expect(screen.getByText('Password is required')).toBeInTheDocument();
expect(mockSubmit).not.toHaveBeenCalled();
});
test('updates input values when typing', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
await user.type(emailInput, '[email protected]');
await user.type(passwordInput, 'password123');
expect(emailInput).toHaveValue('[email protected]');
expect(passwordInput).toHaveValue('password123');
});
});
Testing Async Behavior
Async Data Loading
// UserList.js
import { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUsers()
.then(setUsers)
.catch(setError)
.finally(() => setLoading(false));
}, []);
if (loading) return <div>Loading users...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// Mock API function
async function fetchUsers() {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch users');
return response.json();
}
export default UserList;
// UserList.test.js
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';
// Mock the fetch function
global.fetch = jest.fn();
describe('UserList', () => {
beforeEach(() => {
fetch.mockClear();
});
test('displays loading state initially', () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => []
});
render(<UserList />);
expect(screen.getByText('Loading users...')).toBeInTheDocument();
});
test('displays users after successful fetch', async () => {
const mockUsers = [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' }
];
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUsers
});
render(<UserList />);
// Wait for users to load
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
expect(screen.queryByText('Loading users...')).not.toBeInTheDocument();
});
test('displays error message when fetch fails', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Error: Network error')).toBeInTheDocument();
});
expect(screen.queryByText('Loading users...')).not.toBeInTheDocument();
});
});
Custom Hooks Testing
Testing Hook Logic
// useCounter.js
import { useState } from 'react';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return {
count,
increment,
decrement,
reset
};
}
export default useCounter;
// 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();
});
expect(result.current.count).toBe(12);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Context Testing
Testing Context Providers
// ThemeContext.js
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === '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.js
import { useTheme } from './ThemeContext';
function ThemedButton() {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
style={{
backgroundColor: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#333' : '#fff'
}}
>
Current theme: {theme}
</button>
);
}
export default ThemedButton;
// ThemedButton.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import ThemedButton from './ThemedButton';
// Helper function to render with context
function renderWithTheme(ui) {
return render(<ThemeProvider>{ui}</ThemeProvider>);
}
describe('ThemedButton', () => {
test('displays current theme', () => {
renderWithTheme(<ThemedButton />);
expect(screen.getByText('Current theme: light')).toBeInTheDocument();
});
test('toggles theme when clicked', () => {
renderWithTheme(<ThemedButton />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Current theme: dark')).toBeInTheDocument();
});
test('throws error when used outside provider', () => {
// Suppress console.error for this test
const originalError = console.error;
console.error = jest.fn();
expect(() => {
render(<ThemedButton />);
}).toThrow('useTheme must be used within ThemeProvider');
console.error = originalError;
});
});
Testing with Mock Functions
Jest Mocks
// Timer.js
import { useState, useEffect } from 'react';
function Timer({ onTick }) {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(prev => {
const newValue = prev + 1;
onTick?.(newValue);
return newValue;
});
}, 1000);
return () => clearInterval(interval);
}, [onTick]);
return <div>Timer: {seconds}s</div>;
}
export default Timer;
// Timer.test.js
import { render, screen, act } from '@testing-library/react';
import Timer from './Timer';
jest.useFakeTimers();
describe('Timer', () => {
afterEach(() => {
jest.clearAllTimers();
});
test('starts at 0 seconds', () => {
render(<Timer />);
expect(screen.getByText('Timer: 0s')).toBeInTheDocument();
});
test('increments every second', () => {
render(<Timer />);
// Fast forward 3 seconds
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.getByText('Timer: 3s')).toBeInTheDocument();
});
test('calls onTick with current value', () => {
const mockOnTick = jest.fn();
render(<Timer onTick={mockOnTick} />);
act(() => {
jest.advanceTimersByTime(2000);
});
expect(mockOnTick).toHaveBeenCalledTimes(2);
expect(mockOnTick).toHaveBeenNthCalledWith(1, 1);
expect(mockOnTick).toHaveBeenNthCalledWith(2, 2);
});
test('cleans up timer on unmount', () => {
const { unmount } = render(<Timer />);
// Spy on clearInterval
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
clearIntervalSpy.mockRestore();
});
});
Best Practices
- Test behavior, not implementation - Focus on what users experience
- Use meaningful test names - Describe what the test does
- Keep tests simple - One assertion per test when possible
- Use setup and teardown - Clean up after tests
- Mock external dependencies - Don't test the network
- Test error states - Ensure graceful error handling
- Use accessibility queries - Prefer role-based queries
- Write tests first - TDD approach helps design better APIs
Testing is essential for maintaining reliable React applications. Start with these basics and gradually add more sophisticated testing patterns!