Testing React Components
Introduction to Testing React Components
Testing is a critical part of building reliable React applications. In this lesson, we'll explore how to test React components using Jest and React Testing Library, the industry-standard tools for testing React applications.
Why Test React Components?
Testing React components provides several benefits:
- Confidence: Ensure your components work as expected
- Regression Prevention: Catch bugs when making changes
- Documentation: Tests serve as living documentation
- Refactoring Safety: Change code confidently with test coverage
- Better Design: Writing tests encourages better component design
Setting Up Jest and React Testing Library
Create React App comes with Jest and React Testing Library pre-configured. For custom setups:
npm install --save-dev @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event
// Import Jest DOM matchers
import '@testing-library/jest-dom';
// Optional: Add custom matchers or global test setup
global.matchMedia = global.matchMedia || function() {
return {
matches: false,
addListener: jest.fn(),
removeListener: jest.fn(),
};
};
Your First Component Test
Let's start with a simple component and test it:
function Button({ onClick, children, disabled = false }) {
return (
<button
onClick={onClick}
disabled={disabled}
className="btn"
>
{children}
</button>
);
}
export default Button;
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';
describe('Button Component', () => {
test('renders button with text', () => {
render(<Button>Click Me</Button>);
const button = screen.getByRole('button', { name: /click me/i });
expect(button).toBeInTheDocument();
});
test('calls onClick when clicked', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click Me</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('does not call onClick when disabled', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick} disabled>Click Me</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(handleClick).not.toHaveBeenCalled();
expect(button).toBeDisabled();
});
});
describe blocks to group related tests and give each test a clear, descriptive name that explains what it's testing.
Query Methods in React Testing Library
React Testing Library provides several query methods to find elements:
// 1. Accessible queries (preferred)
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/username/i)
screen.getByPlaceholderText(/enter email/i)
screen.getByText(/welcome back/i)
// 2. Semantic queries
screen.getByAltText(/profile picture/i)
screen.getByTitle(/close dialog/i)
// 3. Test IDs (last resort)
screen.getByTestId('custom-element')
// Query variants:
// getBy* - throws error if not found (single element)
// queryBy* - returns null if not found (single element)
// findBy* - returns promise, waits for element (async)
// getAllBy* - returns array, throws if none found
// queryAllBy* - returns empty array if none found
// findAllBy* - returns promise with array
Testing Component with State
Let's test a component that manages state:
import { useState } from 'react';
function Counter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Count: <span data-testid="count-value">{count}</span></p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
export default Counter;
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
describe('Counter Component', () => {
test('renders with initial count', () => {
render(<Counter initialCount={5} />);
const countValue = screen.getByTestId('count-value');
expect(countValue).toHaveTextContent('5');
});
test('increments count when increment button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
const incrementButton = screen.getByRole('button', { name: /increment/i });
await user.click(incrementButton);
const countValue = screen.getByTestId('count-value');
expect(countValue).toHaveTextContent('1');
});
test('decrements count when decrement button is clicked', async () => {
const user = userEvent.setup();
render(<Counter initialCount={10} />);
const decrementButton = screen.getByRole('button', { name: /decrement/i });
await user.click(decrementButton);
const countValue = screen.getByTestId('count-value');
expect(countValue).toHaveTextContent('9');
});
test('resets count to zero', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
const resetButton = screen.getByRole('button', { name: /reset/i });
await user.click(resetButton);
const countValue = screen.getByTestId('count-value');
expect(countValue).toHaveTextContent('0');
});
test('performs multiple interactions correctly', async () => {
const user = userEvent.setup();
render(<Counter />);
const incrementButton = screen.getByRole('button', { name: /increment/i });
const decrementButton = screen.getByRole('button', { name: /decrement/i });
const countValue = screen.getByTestId('count-value');
await user.click(incrementButton);
await user.click(incrementButton);
await user.click(incrementButton);
expect(countValue).toHaveTextContent('3');
await user.click(decrementButton);
expect(countValue).toHaveTextContent('2');
});
});
Testing Async Operations
Many components perform asynchronous operations. React Testing Library provides utilities for testing async behavior:
import { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('https://api.example.com/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>Error: {error}</p>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
export default UserList;
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';
// Mock fetch globally
global.fetch = jest.fn();
describe('UserList Component', () => {
beforeEach(() => {
fetch.mockClear();
});
test('displays loading state initially', () => {
fetch.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<UserList />);
expect(screen.getByText(/loading users/i)).toBeInTheDocument();
});
test('displays users after successful fetch', async () => {
const mockUsers = [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' }
];
fetch.mockResolvedValueOnce({
json: async () => mockUsers
});
render(<UserList />);
// Wait for users to appear
const userItems = await screen.findAllByRole('listitem');
expect(userItems).toHaveLength(2);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
test('displays error message on fetch failure', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
render(<UserList />);
// Wait for error message
const errorMessage = await screen.findByText(/error: network error/i);
expect(errorMessage).toBeInTheDocument();
});
test('loading indicator disappears after data loads', async () => {
fetch.mockResolvedValueOnce({
json: async () => [{ id: 1, name: 'Test User' }]
});
render(<UserList />);
expect(screen.getByText(/loading users/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByText(/loading users/i)).not.toBeInTheDocument();
});
});
});
await with async queries like findBy* or waitFor can lead to flaky tests. Always await async operations in tests.
User Event vs FireEvent
React Testing Library provides two ways to simulate user interactions:
import { render, screen, fireEvent } from '@testing-library/react';
test('using fireEvent', () => {
render(<Input />);
const input = screen.getByRole('textbox');
// Fires a single event
fireEvent.change(input, { target: { value: 'Hello' } });
fireEvent.click(screen.getByRole('button'));
});
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('using userEvent', async () => {
const user = userEvent.setup();
render(<Input />);
const input = screen.getByRole('textbox');
// Simulates real user interactions (multiple events)
await user.type(input, 'Hello');
await user.click(screen.getByRole('button'));
// Other useful methods:
await user.hover(element);
await user.unhover(element);
await user.selectOptions(select, 'option1');
await user.clear(input);
await user.keyboard('{Enter}');
await user.tab();
});
userEvent over fireEvent because it more closely simulates real user interactions by firing multiple events in the correct sequence.
Exercise 1: Test a Form Component
Create a login form component with email and password fields, and write tests for:
- Rendering all form elements correctly
- Validating empty fields show error messages
- Calling onSubmit with correct data when form is valid
- Disabling submit button while submitting
Exercise 2: Test a Search Component
Create a search component that fetches results from an API. Write tests for:
- Showing no results initially
- Displaying loading state while searching
- Showing search results after successful API call
- Handling API errors gracefully
- Debouncing search input (advanced)
Exercise 3: Test a Toggle Component
Create a toggle switch component and write tests for:
- Initial checked/unchecked state
- Toggling state on click
- Calling onChange callback with correct value
- Accessibility (can be toggled with keyboard)
- Disabled state prevents toggling
Jest Matchers for React Testing
Common matchers from @testing-library/jest-dom:
// Presence
expect(element).toBeInTheDocument()
expect(element).not.toBeInTheDocument()
// Visibility
expect(element).toBeVisible()
expect(element).not.toBeVisible()
// Values
expect(input).toHaveValue('test')
expect(checkbox).toBeChecked()
expect(button).toBeDisabled()
// Text content
expect(element).toHaveTextContent('Hello')
expect(element).toContainHTML('<span>Test</span>')
// Attributes
expect(element).toHaveAttribute('href', '/home')
expect(element).toHaveClass('active')
expect(element).toHaveStyle({ color: 'red' })
// Form elements
expect(input).toBeRequired()
expect(input).toBeInvalid()
expect(select).toHaveDisplayValue('Option 1')
// Focus
expect(input).toHaveFocus()
// Accessibility
expect(element).toHaveAccessibleName('Submit button')
expect(element).toHaveAccessibleDescription('Click to submit')
npm test to run tests in watch mode, or npm test -- --coverage to see code coverage reports.