Testing React Components
Use React Testing Library to test component behavior, user interactions, hooks, forms, and async patterns accessibly
The React Testing Library Philosophy
React Testing Library encourages testing components the way users interact with them. Instead of testing internal state or implementation details, you query the DOM by accessible roles, labels, and text -- the same things a real user sees and interacts with.
Guiding Principle:
"The more your tests resemble the way your software is used, the more confidence they can give you." - Kent C. Dodds
Querying the DOM
import { render, screen } from '@testing-library/react';
import { ProductCard } from './ProductCard';
test('renders product information', () => {
render(
<ProductCard
name="Wireless Headphones"
price={79.99}
rating={4.5}
inStock={true}
/>
);
// Preferred: query by role (accessible to screen readers)
expect(screen.getByRole('heading', { name: 'Wireless Headphones' }))
.toBeInTheDocument();
// Query by text content
expect(screen.getByText('$79.99')).toBeInTheDocument();
expect(screen.getByText(/4\.5/)).toBeInTheDocument();
// Query by label (for form elements)
// screen.getByLabelText('Quantity');
// Query by test ID (last resort)
// screen.getByTestId('product-card');
// Check element is NOT present
expect(screen.queryByText('Out of Stock')).not.toBeInTheDocument();
// Accessible role check
expect(screen.getByRole('button', { name: 'Add to Cart' }))
.toBeEnabled();
});
// Priority of queries (best to worst):
// 1. getByRole - accessible queries
// 2. getByLabelText - form fields
// 3. getByPlaceholderText - inputs
// 4. getByText - visible text
// 5. getByDisplayValue - current input value
// 6. getByAltText - images
// 7. getByTitle - title attribute
// 8. getByTestId - last resort
Testing User Events
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchFilter } from './SearchFilter';
test('filters items as user types', async () => {
const user = userEvent.setup();
const onFilter = jest.fn();
render(<SearchFilter items={['Apple', 'Banana', 'Cherry']} onFilter={onFilter} />);
const input = screen.getByRole('searchbox', { name: 'Search items' });
// Type in search box
await user.type(input, 'App');
expect(onFilter).toHaveBeenLastCalledWith('App');
// Clear and type again
await user.clear(input);
await user.type(input, 'Ban');
expect(onFilter).toHaveBeenLastCalledWith('Ban');
// Click a button
await user.click(screen.getByRole('button', { name: 'Clear' }));
expect(input).toHaveValue('');
});
test('handles keyboard navigation', async () => {
const user = userEvent.setup();
render(<DropdownMenu items={['Edit', 'Delete', 'Archive']} />);
// Open dropdown
await user.click(screen.getByRole('button', { name: 'Options' }));
// Navigate with keyboard
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(screen.getByText('Item deleted')).toBeInTheDocument();
});
Testing Forms
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RegistrationForm } from './RegistrationForm';
test('submits form with valid data', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<RegistrationForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Name'), 'Alice Johnson');
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'Str0ngP@ss!');
// Select from dropdown
await user.selectOptions(screen.getByLabelText('Role'), 'developer');
// Check a checkbox
await user.click(screen.getByLabelText('Accept Terms'));
// Submit
await user.click(screen.getByRole('button', { name: 'Register' }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'Alice Johnson',
email: 'alice@example.com',
password: 'Str0ngP@ss!',
role: 'developer',
acceptTerms: true,
});
});
});
test('shows validation errors', async () => {
const user = userEvent.setup();
render(<RegistrationForm onSubmit={jest.fn()} />);
// Submit without filling fields
await user.click(screen.getByRole('button', { name: 'Register' }));
expect(await screen.findByText('Name is required')).toBeInTheDocument();
expect(await screen.findByText('Email is required')).toBeInTheDocument();
});
Testing Async Behavior & Custom Hooks
// Testing async data fetching
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';
test('loads and displays user data', async () => {
// Assuming MSW is configured for /api/users/1
render(<UserProfile userId={1} />);
// Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for data
expect(await screen.findByText('Alice Johnson')).toBeInTheDocument();
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
test('displays error state', async () => {
// Override MSW to return error for this test
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json(null, { status: 500 });
})
);
render(<UserProfile userId={999} />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
// Testing custom hooks with renderHook
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('useCounter hook', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
act(() => result.current.increment());
expect(result.current.count).toBe(11);
act(() => result.current.decrement());
expect(result.current.count).toBe(10);
act(() => result.current.reset());
expect(result.current.count).toBe(10);
});
Key Takeaways
- Query by role and label first -- it tests accessibility for free
- Use userEvent over fireEvent for realistic interactions
- Use findBy for async elements, queryBy to assert absence
- Test behavior from the user perspective, not component internals
- Combine with MSW for realistic data-fetching tests