Why Test React Components?
React components are the building blocks of your user interface, making them critical to your application's functionality and user experience. Testing them thoroughly helps ensure:
- Correctness - Components render as expected under various conditions
- Reliability - Components handle user interactions properly
- Regressions - Changes don't break existing functionality
- Accessibility - Components are usable by all people
- Documentation - Tests serve as living documentation for component behavior
Real-world analogy: Think of component testing as quality control for car manufacturing. Before assembling the entire car (your application), you need to ensure that each part (component) functions correctly both individually and when integrated with other parts.
React Testing Tools
React Testing Library
React Testing Library (RTL) has become the standard for testing React components. It's a lightweight solution focused on testing components from the user's perspective, encouraging good testing practices.
Setting Up the Testing Environment
If you're using Create React App, Jest and React Testing Library are included out of the box. Otherwise, you'll need to install:
npm install --save-dev @testing-library/react @testing-library/jest-dom jest
And add to your Jest setup file:
// setupTests.js
import '@testing-library/jest-dom';
Other Useful Testing Tools
- @testing-library/jest-dom - Custom Jest matchers for DOM testing
- @testing-library/user-event - Simulates user events more realistically than fireEvent
- jest-axe - Tests for accessibility violations
- msw - Mock Service Worker for API mocking
React Testing Library Philosophy
"The more your tests resemble the way your software is used, the more confidence they can give you." - Kent C. Dodds, creator of Testing Library
Key principles that guide React Testing Library:
- Test behavior, not implementation - Focus on what components do, not how they do it
- Find elements as users would - Query by accessible roles, labels, and text rather than test IDs
- Accessibility comes first - If a component isn't accessible, it's not properly testable
- Avoid testing implementation details - Don't test state directly or component methods
Real-world example: Testing Library encourages you to think like a user, not a developer. Users don't care about component state or props - they care about what they see and interact with. Similarly, shoppers don't care about the internal store inventory system - they care about finding and purchasing products.
Basic Component Testing
Let's create and test a simple button component that changes text when clicked:
// ToggleButton.jsx
import React, { useState } from 'react';
function ToggleButton({ initialText, toggledText }) {
const [isToggled, setIsToggled] = useState(false);
return (
<button
onClick={() => setIsToggled(!isToggled)}
data-testid="toggle-button"
>
{isToggled ? toggledText : initialText}
</button>
);
}
export default ToggleButton;
Now, let's write a test for this component:
// ToggleButton.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ToggleButton from './ToggleButton';
describe('ToggleButton', () => {
test('displays initial text when not clicked', () => {
render(<ToggleButton initialText="Click me" toggledText="Clicked!" />);
// Find the button by its text
const buttonElement = screen.getByText(/click me/i);
// Assert that the button exists and has the right text
expect(buttonElement).toBeInTheDocument();
});
test('displays toggled text after being clicked', () => {
render(<ToggleButton initialText="Click me" toggledText="Clicked!" />);
// Find the button (using the test ID this time, for demonstration)
const buttonElement = screen.getByTestId('toggle-button');
// Simulate a user clicking the button
fireEvent.click(buttonElement);
// Check that the text changed
expect(buttonElement).toHaveTextContent(/clicked!/i);
// Click again and check that it goes back to initial text
fireEvent.click(buttonElement);
expect(buttonElement).toHaveTextContent(/click me/i);
});
});
Querying Elements
React Testing Library provides several methods to query elements, with each having a different priority based on accessibility best practices:
Query Variants
- getBy* - Returns the matching element or throws an error
- queryBy* - Returns the matching element or null (useful for checking absence)
- findBy* - Returns a Promise that resolves to the element (useful for async rendering)
- getAllBy*, queryAllBy*, findAllBy* - Variants that return arrays of elements
Query Methods (from highest to lowest priority)
// Recommended queries (in order of preference)
const button = screen.getByRole('button', { name: /submit/i });
const input = screen.getByLabelText(/email/i);
const heading = screen.getByText(/welcome/i);
const img = screen.getByAltText(/profile picture/i);
const element = screen.getByTitle(/information/i);
// Less recommended queries
const custom = screen.getByTestId('custom-element');
// Examples of variant usage
// Check if an error message is NOT present
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
// Wait for loading message to disappear
await waitForElementToBeRemoved(() =>
screen.queryByText(/loading/i)
);
Real-world analogy: Think of these query methods as different ways to identify people in a crowd. You could identify someone by their role ("the chef"), by their name tag, by what they're saying, or as a last resort, by a special badge you gave them. The most natural and accessible methods should be preferred.
Testing User Interactions
To test user interactions, React Testing Library provides two approaches: fireEvent and userEvent.
Using fireEvent
// Basic fireEvent usage
import { fireEvent } from '@testing-library/react';
test('input value updates when typed into', () => {
render(<input data-testid="input" />);
const input = screen.getByTestId('input');
fireEvent.change(input, { target: { value: 'Hello World' } });
expect(input).toHaveValue('Hello World');
});
Using userEvent (preferred)
userEvent provides a more realistic simulation of user behavior:
// More realistic userEvent usage
import userEvent from '@testing-library/user-event';
test('input value updates when typed into', async () => {
const user = userEvent.setup();
render(<input data-testid="input" />);
const input = screen.getByTestId('input');
// This actually simulates keypress events for each character
await user.type(input, 'Hello World');
expect(input).toHaveValue('Hello World');
});
Common User Interactions
// Click
await user.click(button);
// Double-click
await user.dblClick(button);
// Typing
await user.type(input, 'Hello');
// Keyboard interactions
await user.keyboard('{Enter}');
// Clearing input
await user.clear(input);
// Selecting options
await user.selectOptions(select, ['option1', 'option2']);
Testing a Form Component
Let's test a more complex signup form component:
// SignupForm.jsx
import React, { useState } from 'react';
function SignupForm({ onSubmit }) {
const [formData, setFormData] = useState({
username: '',
email: '',
password: ''
});
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const validateForm = () => {
const newErrors = {};
if (!formData.username) {
newErrors.username = 'Username is required';
}
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Email is invalid';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 6) {
newErrors.password = 'Password must be at least 6 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validateForm()) {
onSubmit(formData);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
value={formData.username}
onChange={handleChange}
aria-describedby={errors.username ? "username-error" : undefined}
/>
{errors.username && (
<span id="username-error" role="alert">{errors.username}</span>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">{errors.email}</span>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
aria-describedby={errors.password ? "password-error" : undefined}
/>
{errors.password && (
<span id="password-error" role="alert">{errors.password}</span>
)}
</div>
<button type="submit">Sign Up</button>
</form>
);
}
export default SignupForm;
Now, let's write tests for this component:
// SignupForm.test.jsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SignupForm from './SignupForm';
describe('SignupForm', () => {
test('renders all form fields and submit button', () => {
render(<SignupForm onSubmit={() => {}} />);
// Check that all inputs and button exist
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign up/i })).toBeInTheDocument();
});
test('displays validation errors when form is submitted with empty fields', async () => {
const user = userEvent.setup();
render(<SignupForm onSubmit={() => {}} />);
// Submit the form without filling any fields
await user.click(screen.getByRole('button', { name: /sign up/i }));
// Check that error messages are displayed
expect(screen.getByText(/username is required/i)).toBeInTheDocument();
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
test('displays validation error for invalid email format', async () => {
const user = userEvent.setup();
render(<SignupForm onSubmit={() => {}} />);
// Enter an invalid email
await user.type(screen.getByLabelText(/email/i), 'invalid-email');
// Submit the form
await user.click(screen.getByRole('button', { name: /sign up/i }));
// Check that the correct error message is displayed
expect(screen.getByText(/email is invalid/i)).toBeInTheDocument();
});
test('displays validation error for short password', async () => {
const user = userEvent.setup();
render(<SignupForm onSubmit={() => {}} />);
// Enter a short password
await user.type(screen.getByLabelText(/password/i), 'short');
// Submit the form
await user.click(screen.getByRole('button', { name: /sign up/i }));
// Check that the correct error message is displayed
expect(screen.getByText(/password must be at least 6 characters/i)).toBeInTheDocument();
});
test('calls onSubmit with form data when valid form is submitted', async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn();
render(<SignupForm onSubmit={mockSubmit} />);
// Fill out the form with valid data
await user.type(screen.getByLabelText(/username/i), 'testuser');
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
// Submit the form
await user.click(screen.getByRole('button', { name: /sign up/i }));
// Check that onSubmit was called with the correct data
expect(mockSubmit).toHaveBeenCalledTimes(1);
expect(mockSubmit).toHaveBeenCalledWith({
username: 'testuser',
email: 'test@example.com',
password: 'password123'
});
});
});
Testing Asynchronous Components
Many React components perform asynchronous operations, such as API calls or delayed updates. Testing these requires special approaches.
A Simple Async Component
// UserProfile.jsx
import React, { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchUser() {
try {
setLoading(true);
setError(null);
// Simulate API call
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const userData = await response.json();
setUser(userData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]);
if (loading) {
return <div>Loading user data...</div>;
}
if (error) {
return <div role="alert">Error: {error}</div>;
}
if (!user) {
return <div>No user found</div>;
}
return (
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Role: {user.role}</p>
</div>
);
}
Mocking Fetch Calls
We need to mock the fetch API to test this component:
// UserProfile.test.jsx
import React from 'react';
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import UserProfile from './UserProfile';
// Mock the fetch function
global.fetch = jest.fn();
describe('UserProfile', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('shows loading state initially', () => {
// Mock fetch to return a promise that never resolves
global.fetch.mockImplementation(() => new Promise(() => {}));
render(<UserProfile userId="123" />);
expect(screen.getByText(/loading user data/i)).toBeInTheDocument();
});
test('renders user data when fetch succeeds', async () => {
// Mock successful fetch response
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: '123',
name: 'John Doe',
email: 'john@example.com',
role: 'Admin'
})
});
render(<UserProfile userId="123" />);
// Wait for loading message to disappear
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
// Check that user data is displayed
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText(/email: john@example.com/i)).toBeInTheDocument();
expect(screen.getByText(/role: admin/i)).toBeInTheDocument();
});
test('shows error message when fetch fails', async () => {
// Mock failed fetch response
global.fetch.mockResolvedValueOnce({
ok: false
});
render(<UserProfile userId="123" />);
// Wait for loading message to disappear
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
// Check that error message is displayed
expect(screen.getByRole('alert')).toHaveTextContent(/failed to fetch user/i);
});
});
Using Mock Service Worker (MSW)
For more complex API mocking, Mock Service Worker is a better solution:
// Example setup with MSW
import { rest } from 'msw';
import { setupServer } from 'msw/node';
// Setup request handlers
const server = setupServer(
rest.get('https://api.example.com/users/:userId', (req, res, ctx) => {
const { userId } = req.params;
if (userId === '123') {
return res(
ctx.json({
id: '123',
name: 'John Doe',
email: 'john@example.com',
role: 'Admin'
})
);
}
return res(ctx.status(404));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Testing React Hooks
Testing custom hooks requires a slightly different approach. React Testing Library provides a renderHook utility for this purpose.
// useCounter.js - a simple custom hook
import { useState, useCallback } from 'react';
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 };
}
export default useCounter;
Testing this hook:
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
describe('useCounter', () => {
test('initializes with default value of 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with provided value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments the counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements the counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets the counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(7);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(5);
});
test('updates when initial value changes', () => {
const { result, rerender } = renderHook(({ initialValue }) => useCounter(initialValue), {
initialProps: { initialValue: 0 }
});
// Rerender the hook with new props
rerender({ initialValue: 10 });
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Best Practices for React Component Testing
Best Practices)) Test from user perspective Find elements by accessibility attributes Test interactions as a user would Keep tests focused Test one behavior per test Use clear test descriptions Avoid testing implementation details Organize tests effectively Group related tests Use descriptive test names Consistent patterns Handle async properly Wait for elements to appear Mock external dependencies Clear async patterns Consider edge cases Error states Loading states Empty states
- Test behavior, not implementation - Focus on what the component does, not how it does it
- Find elements as users would - Use accessible queries like getByRole, getByLabelText, getByText
- Use specific assertions - toBeInTheDocument(), toHaveTextContent(), toBeDisabled()
- Test loading, success, and error states - Include tests for all component states
- Mock external dependencies - Use jest.mock() or Mock Service Worker for API calls
- Test accessibility - Use jest-axe to check for accessibility violations
- Keep tests independent - Each test should run in isolation without depending on other tests
Real-world example: At GitHub, testing React components is a critical part of their development process. They discovered that focusing on testing user interactions rather than implementation details made their tests more maintainable as the codebase evolved. When they rewrote parts of their UI, tests that followed RTL principles needed minimal changes, while tests that were tightly coupled to implementation details had to be completely rewritten.
Component Testing Patterns
Testing Props and Rendering
test('renders correctly with different props', () => {
const { rerender } = render(<Greeting name="John" />);
expect(screen.getByText(/hello, john/i)).toBeInTheDocument();
// Re-render with different props
rerender(<Greeting name="Jane" />);
expect(screen.getByText(/hello, jane/i)).toBeInTheDocument();
});
Testing Conditional Rendering
test('shows premium content when user is premium', () => {
render(<Dashboard userType="premium" />);
expect(screen.getByText(/premium content/i)).toBeInTheDocument();
// Use queryBy for elements that might not exist
expect(screen.queryByText(/upgrade to premium/i)).not.toBeInTheDocument();
});
Testing User Interactions
test('updates counter when button is clicked', async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
expect(screen.getByText(/count: 0/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});
Testing Forms
test('submits form with user input', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/username/i), 'testuser');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(handleSubmit).toHaveBeenCalledWith({
username: 'testuser',
password: 'password123'
});
});
Debugging Component Tests
When tests fail, React Testing Library provides several utilities to help you debug:
// Visual debugging
import { screen } from '@testing-library/react';
test('debugging example', () => {
render(<MyComponent />);
// Print the DOM to console
screen.debug();
// Print a specific element
screen.debug(screen.getByRole('button'));
});
// logRoles helper for accessibility debugging
import { logRoles } from '@testing-library/dom';
test('log roles for debugging', () => {
const { container } = render(<MyComponent />);
// Log all the accessible roles
logRoles(container);
});
Common debugging techniques:
- Use
screen.debug()to visualize the rendered output - Use
logRoles(container)to see all accessible roles - Run tests with
--verboseflag for more details - Use
console.logto check variables during test execution - Set
--testTimeout=100000and addawait new Promise(r => setTimeout(r, 50000))to pause tests for manual inspection
Testing Connected Components
Testing components connected to state management libraries like Redux or Context API requires some special handling:
Testing Components with Context
// Custom render function with ThemeContext provider
const customRender = (ui, { theme = 'light', ...renderOptions } = {}) => {
return render(
<ThemeContext.Provider value={{ theme }}>
{ui}
</ThemeContext.Provider>,
renderOptions
);
};
test('renders with theme from context', () => {
customRender(<ThemedButton />, { theme: 'dark' });
expect(screen.getByRole('button')).toHaveClass('dark-theme');
});
Testing Redux Connected Components
// test-utils.js
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import rootReducer from '../reducers';
function renderWithRedux(
ui,
{ initialState, store = createStore(rootReducer, initialState) } = {}
) {
return {
...render(<Provider store={store}>{ui}</Provider>),
store
};
}
// Component test
test('counter works with redux store', async () => {
const user = userEvent.setup();
const { store } = renderWithRedux(<ConnectedCounter />, {
initialState: { counter: { value: 5 } }
});
expect(screen.getByText(/count: 5/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText(/count: 6/i)).toBeInTheDocument();
expect(store.getState().counter.value).toBe(6);
});
Practical Exercise
Exercise: Testing a Todo List Component
Let's practice by writing tests for a todo list component:
// TodoList.jsx
import React, { useState } from 'react';
function TodoList() {
const [todos, setTodos] = useState([]);
const [inputValue, setInputValue] = useState('');
const handleInputChange = (e) => {
setInputValue(e.target.value);
};
const handleAddTodo = () => {
if (inputValue.trim()) {
setTodos([...todos, { id: Date.now(), text: inputValue, completed: false }]);
setInputValue('');
}
};
const handleToggleTodo = (id) => {
setTodos(
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const handleDeleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
return (
<div>
<h2>Todo List</h2>
<div>
<label htmlFor="new-todo">Add Todo:</label>
<input
id="new-todo"
value={inputValue}
onChange={handleInputChange}
aria-label="New todo"
/>
<button onClick={handleAddTodo}>Add</button>
</div>
{todos.length === 0 ? (
<p>No todos yet. Add one above!</p>
) : (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggleTodo(todo.id)}
aria-label={`Mark "${todo.text}" as ${todo.completed ? 'incomplete' : 'complete'}`}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button
onClick={() => handleDeleteTodo(todo.id)}
aria-label={`Delete "${todo.text}"`}
>
Delete
</button>
</li>
))}
</ul>
)}
</div>
);
}
export default TodoList;
Write tests to verify:
- Initial state shows "No todos yet" message
- Adding a todo updates the list
- Marking a todo as complete adds line-through styling
- Deleting a todo removes it from the list
Summary
- React Testing Library promotes testing components from the user's perspective
- Query for elements using accessible attributes (role, label, text) rather than implementation details
- Simulate user interactions with fireEvent or userEvent (preferred)
- Test async behavior using findBy* queries and waitFor utilities
- Use custom render functions to provide necessary context or state
- Focus on testing behavior, not implementation details
- Include tests for all component states (loading, success, error, empty)
Remember: Good component tests serve as both a safety net for refactoring and as documentation for how components should behave.
Assignment
Create a complete test suite for a shopping cart component with the following features:
- Display a list of products with name, price, and "Add to Cart" button
- Show a cart summary with added items, quantities, and total price
- Allow increasing/decreasing quantities of items in the cart
- Remove items from the cart
- Apply a discount code
Your assignment should include:
- The ShoppingCart component implementation
- A comprehensive test suite covering all functionality
- Tests for edge cases (empty cart, maximum quantities, invalid discount codes)
- At least one custom hook test
Bonus: Use context API for state management and test components that consume the context.