Testing Library
React Testing Library
React Testing Library is a simple and complete testing utility that encourages good testing practices. It focuses on testing components the way users interact with them.
Core Principles
- Test what users see - Query by text, labels, and roles
- Test behavior, not implementation - Avoid testing internal state
- Find elements like users do - Use accessible queries first
- Write maintainable tests - Tests should break when behavior changes
Queries Reference
Query Types
// getBy* - Throws error if not found (use for elements that should exist)
screen.getByText('Submit');
// queryBy* - Returns null if not found (use for elements that might not exist)
screen.queryByText('Optional text');
// findBy* - Returns promise, waits for element (use for async elements)
await screen.findByText('Async content');
// getAllBy*, queryAllBy*, findAllBy* - Multiple elements
screen.getAllByRole('button');
Query Priority
describe('Query examples', () => {
const TestComponent = () => (
<div>
<h1>Dashboard</h1>
<label htmlFor="username">Username</label>
<input
id="username"
placeholder="Enter your username"
aria-label="Username input"
data-testid="username-field"
/>
<button type="submit">Submit Form</button>
<img src="/logo.png" alt="Company logo" />
</div>
);
beforeEach(() => {
render(<TestComponent />);
});
test('accessible queries (highest priority)', () => {
// By role (most preferred)
screen.getByRole('heading', { name: 'Dashboard' });
screen.getByRole('textbox', { name: 'Username' });
screen.getByRole('button', { name: 'Submit Form' });
// By label text
screen.getByLabelText('Username');
screen.getByLabelText(/username/i); // Case insensitive regex
});
test('semantic queries (medium priority)', () => {
// By placeholder text
screen.getByPlaceholderText('Enter your username');
// By text content
screen.getByText('Dashboard');
screen.getByText(/submit/i);
// By alt text
screen.getByAltText('Company logo');
});
test('test id queries (last resort)', () => {
// Only use when other queries don't work
screen.getByTestId('username-field');
});
});
Advanced Queries
Custom Text Matcher
function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
<button>
{todo.completed ? 'Mark Incomplete' : 'Mark Complete'}
</button>
</li>
))}
</ul>
);
}
describe('TodoList queries', () => {
const todos = [
{ id: 1, text: 'Learn React Testing Library', completed: false },
{ id: 2, text: 'Write comprehensive tests', completed: true }
];
beforeEach(() => {
render(<TodoList todos={todos} />);
});
test('finds todos with different matchers', () => {
// Exact string match
screen.getByText('Learn React Testing Library');
// Regex match
screen.getByText(/learn react/i);
// Function matcher
screen.getByText((content, element) => {
return element?.tagName.toLowerCase() === 'span' &&
content.includes('Testing Library');
});
});
test('queries with multiple criteria', () => {
// Find button with specific text for incomplete todo
const incompleteButton = screen.getByRole('button', {
name: 'Mark Complete'
});
expect(incompleteButton).toBeInTheDocument();
// Find all buttons and filter
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2);
});
});
Within Queries
function UserCard({ user, onEdit, onDelete }) {
return (
<div data-testid={`user-card-${user.id}`}>
<h3>{user.name}</h3>
<p>{user.email}</p>
<div className="actions">
<button onClick={() => onEdit(user.id)}>Edit</button>
<button onClick={() => onDelete(user.id)}>Delete</button>
</div>
</div>
);
}
function UserList({ users, onEdit, onDelete }) {
return (
<div>
{users.map(user => (
<UserCard
key={user.id}
user={user}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
);
}
describe('UserList with within queries', () => {
const users = [
{ id: 1, name: 'John Doe', email: '[email protected]' },
{ id: 2, name: 'Jane Smith', email: '[email protected]' }
];
test('interacts with specific user card', () => {
const onEdit = jest.fn();
const onDelete = jest.fn();
render(<UserList users={users} onEdit={onEdit} onDelete={onDelete} />);
// Get specific user card
const johnCard = screen.getByTestId('user-card-1');
// Query within that card
const johnEditButton = within(johnCard).getByText('Edit');
const johnDeleteButton = within(johnCard).getByText('Delete');
fireEvent.click(johnEditButton);
expect(onEdit).toHaveBeenCalledWith(1);
fireEvent.click(johnDeleteButton);
expect(onDelete).toHaveBeenCalledWith(1);
});
});
User Events
userEvent vs fireEvent
import userEvent from '@testing-library/user-event';
function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
subject: 'general',
message: '',
newsletter: false
});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
return (
<form>
<input
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Full name"
/>
<input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="Email address"
/>
<select
name="subject"
value={formData.subject}
onChange={handleChange}
>
<option value="general">General Inquiry</option>
<option value="support">Support</option>
<option value="billing">Billing</option>
</select>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Your message"
/>
<label>
<input
name="newsletter"
type="checkbox"
checked={formData.newsletter}
onChange={handleChange}
/>
Subscribe to newsletter
</label>
<button type="submit">Send Message</button>
</form>
);
}
describe('ContactForm user interactions', () => {
// userEvent provides more realistic user interactions
test('user can fill out the form', async () => {
const user = userEvent.setup();
render(<ContactForm />);
// Type in text inputs
await user.type(
screen.getByPlaceholderText('Full name'),
'John Doe'
);
await user.type(
screen.getByPlaceholderText('Email address'),
'[email protected]'
);
// Select from dropdown
await user.selectOptions(
screen.getByRole('combobox'),
'support'
);
// Type in textarea
await user.type(
screen.getByPlaceholderText('Your message'),
'I need help with my account'
);
// Check checkbox
await user.click(screen.getByLabelText('Subscribe to newsletter'));
// Verify form state
expect(screen.getByPlaceholderText('Full name')).toHaveValue('John Doe');
expect(screen.getByPlaceholderText('Email address')).toHaveValue('[email protected]');
expect(screen.getByRole('combobox')).toHaveValue('support');
expect(screen.getByPlaceholderText('Your message')).toHaveValue('I need help with my account');
expect(screen.getByLabelText('Subscribe to newsletter')).toBeChecked();
});
// Compare with fireEvent (less realistic)
test('fireEvent alternative (not recommended)', () => {
render(<ContactForm />);
const nameInput = screen.getByPlaceholderText('Full name');
// fireEvent doesn't trigger all the same events as real user interaction
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
expect(nameInput).toHaveValue('John Doe');
});
});
Keyboard Interactions
function SearchBox({ onSearch }) {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
onSearch(query);
};
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
setIsOpen(false);
setQuery('');
}
};
return (
<div>
<form onSubmit={handleSubmit}>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder="Search..."
aria-expanded={isOpen}
/>
<button type="submit">Search</button>
</form>
{isOpen && query && (
<div role="listbox">
<div role="option">Search for "{query}"</div>
</div>
)}
</div>
);
}
describe('SearchBox keyboard interactions', () => {
test('opens dropdown on focus and closes on escape', async () => {
const user = userEvent.setup();
const onSearch = jest.fn();
render(<SearchBox onSearch={onSearch} />);
const searchInput = screen.getByPlaceholderText('Search...');
// Focus input to open dropdown
await user.click(searchInput);
expect(searchInput).toHaveFocus();
expect(searchInput).toHaveAttribute('aria-expanded', 'true');
// Type to show suggestions
await user.type(searchInput, 'react');
expect(screen.getByText('Search for "react"')).toBeInTheDocument();
// Press Escape to close and clear
await user.keyboard('{Escape}');
expect(searchInput).toHaveValue('');
expect(searchInput).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
test('submits search on Enter', async () => {
const user = userEvent.setup();
const onSearch = jest.fn();
render(<SearchBox onSearch={onSearch} />);
const searchInput = screen.getByPlaceholderText('Search...');
await user.type(searchInput, 'react testing');
await user.keyboard('{Enter}');
expect(onSearch).toHaveBeenCalledWith('react testing');
});
});
Testing Async Behavior
waitFor and findBy
function AsyncUserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!userId) return;
setLoading(true);
setError(null);
fetchUser(userId)
.then(userData => {
setUser(userData);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading user...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
<p>Joined: {new Date(user.joinDate).toLocaleDateString()}</p>
</div>
);
}
// Mock API function
const fetchUser = jest.fn();
describe('AsyncUserProfile', () => {
beforeEach(() => {
fetchUser.mockClear();
});
test('displays loading state initially', () => {
fetchUser.mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve({
name: 'John Doe',
email: '[email protected]',
joinDate: '2023-01-15'
}), 100))
);
render(<AsyncUserProfile userId="123" />);
expect(screen.getByText('Loading user...')).toBeInTheDocument();
});
test('displays user data after loading', async () => {
const mockUser = {
name: 'John Doe',
email: '[email protected]',
joinDate: '2023-01-15'
};
fetchUser.mockResolvedValue(mockUser);
render(<AsyncUserProfile userId="123" />);
// Using findBy* for async elements
expect(await screen.findByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Email: [email protected]')).toBeInTheDocument();
// Wait for loading to disappear
await waitFor(() => {
expect(screen.queryByText('Loading user...')).not.toBeInTheDocument();
});
});
test('displays error message on failure', async () => {
fetchUser.mockRejectedValue(new Error('Network error'));
render(<AsyncUserProfile userId="123" />);
expect(await screen.findByText('Error: Network error')).toBeInTheDocument();
});
test('handles user not found', async () => {
fetchUser.mockResolvedValue(null);
render(<AsyncUserProfile userId="123" />);
expect(await screen.findByText('User not found')).toBeInTheDocument();
});
test('refetches when userId changes', async () => {
const user1 = { name: 'John', email: '[email protected]', joinDate: '2023-01-15' };
const user2 = { name: 'Jane', email: '[email protected]', joinDate: '2023-02-20' };
fetchUser
.mockResolvedValueOnce(user1)
.mockResolvedValueOnce(user2);
const { rerender } = render(<AsyncUserProfile userId="1" />);
// Wait for first user
expect(await screen.findByText('John')).toBeInTheDocument();
// Change userId
rerender(<AsyncUserProfile userId="2" />);
// Should show loading again
expect(screen.getByText('Loading user...')).toBeInTheDocument();
// Wait for second user
expect(await screen.findByText('Jane')).toBeInTheDocument();
expect(fetchUser).toHaveBeenCalledTimes(2);
});
});
Custom Render Functions
Provider Wrapper
// test-utils.js
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
// Custom render function with providers
function customRender(ui, {
route = '/',
theme = 'light',
user = null,
...renderOptions
} = {}) {
window.history.pushState({}, 'Test page', route);
function Wrapper({ children }) {
return (
<BrowserRouter>
<AuthProvider initialUser={user}>
<ThemeProvider initialTheme={theme}>
{children}
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
);
}
return render(ui, { wrapper: Wrapper, ...renderOptions });
}
// Re-export everything
export * from '@testing-library/react';
export { customRender as render };
// Component that uses context
function Navigation() {
const { user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
return (
<nav>
<Link to="/">Home</Link>
{user ? (
<>
<Link to="/profile">Profile</Link>
<button onClick={logout}>Logout</button>
</>
) : (
<Link to="/login">Login</Link>
)}
<button onClick={toggleTheme}>
Theme: {theme}
</button>
</nav>
);
}
// Test using custom render
describe('Navigation', () => {
test('shows login link when not authenticated', () => {
render(<Navigation />);
expect(screen.getByText('Login')).toBeInTheDocument();
expect(screen.queryByText('Profile')).not.toBeInTheDocument();
});
test('shows user menu when authenticated', () => {
const mockUser = { id: 1, name: 'John Doe' };
render(<Navigation />, { user: mockUser });
expect(screen.getByText('Profile')).toBeInTheDocument();
expect(screen.getByText('Logout')).toBeInTheDocument();
expect(screen.queryByText('Login')).not.toBeInTheDocument();
});
test('can toggle theme', async () => {
const user = userEvent.setup();
render(<Navigation />, { theme: 'light' });
const themeButton = screen.getByText('Theme: light');
await user.click(themeButton);
expect(screen.getByText('Theme: dark')).toBeInTheDocument();
});
});
Testing Custom Hooks
Complex Hook Testing
// useApi.js
import { useState, useEffect, useCallback } from 'react';
function useApi(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchData = useCallback(async () => {
if (!url) return;
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
const refetch = useCallback(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch };
}
export default useApi;
// useApi.test.js
import { renderHook, waitFor } from '@testing-library/react';
import useApi from './useApi';
global.fetch = jest.fn();
describe('useApi', () => {
beforeEach(() => {
fetch.mockClear();
});
test('fetches data successfully', async () => {
const mockData = { id: 1, name: 'Test Item' };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData
});
const { result } = renderHook(() => useApi('/api/items'));
// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
expect(result.current.error).toBe(null);
// Wait for data to load
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
expect(fetch).toHaveBeenCalledWith('/api/items');
});
test('handles fetch error', async () => {
fetch.mockResolvedValueOnce({
ok: false,
status: 404
});
const { result } = renderHook(() => useApi('/api/items'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBe(null);
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.error.message).toBe('HTTP error! status: 404');
});
test('can refetch data', async () => {
const mockData = { id: 1, name: 'Test Item' };
fetch.mockResolvedValue({
ok: true,
json: async () => mockData
});
const { result } = renderHook(() => useApi('/api/items'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Refetch
act(() => {
result.current.refetch();
});
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(fetch).toHaveBeenCalledTimes(2);
});
test('does not fetch when url is null', () => {
renderHook(() => useApi(null));
expect(fetch).not.toHaveBeenCalled();
});
});
Best Practices
- Use accessible queries - Prefer getByRole, getByLabelText
- Write user-focused tests - Test what users actually do
- Keep tests simple - One behavior per test
- Use userEvent - More realistic than fireEvent
- Test error states - Don't just test the happy path
- Mock external dependencies - Control what you're testing
- Use custom render functions - Simplify provider setup
- Clean up properly - Reset mocks and timers
React Testing Library makes it easy to write tests that give you confidence in your React components!