Introduction to React Component Testing
Testing React components ensures that your UI behaves correctly under different conditions. React Testing Library (RTL) is the most popular testing solution because it encourages testing components the way users interact with them, rather than testing implementation details.
Why React Testing Library?
React Testing Library follows these guiding principles:
- User-centric testing: Test components as users would interact with them
- Implementation agnostic: Focus on what components do, not how they do it
- Accessible by default: Encourages writing accessible components
- Maintainable tests: Tests are less likely to break with refactoring
Note: React Testing Library works with any testing framework (Jest, Vitest, etc.). Most examples use Jest, but the concepts apply universally.
Setting Up React Testing Library
Install the necessary packages:
# For Create React App (already included)
npm test
# For custom setup
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
# For TypeScript
npm install --save-dev @types/jest
Configure Jest with a setup file (setupTests.js):
// src/setupTests.js
import '@testing-library/jest-dom';
// Custom matchers for better assertions
// expect(element).toBeInTheDocument()
// expect(element).toHaveTextContent()
// expect(element).toBeVisible()
Basic Component Rendering
The render() function is the foundation of React Testing Library. It renders a component into a virtual DOM for testing:
import { render, screen } from '@testing-library/react';
import Button from './Button';
test('renders button with text', () => {
render(<Button>Click Me</Button>);
// Query the rendered component
const button = screen.getByRole('button', { name: /click me/i });
// Assert it exists
expect(button).toBeInTheDocument();
});
Tip: Use screen.debug() to print the current DOM structure to the console. This is invaluable for debugging tests.
Queries: Finding Elements
React Testing Library provides several query methods. Choose based on priority:
1. Accessible Queries (Preferred)
// getByRole - Most preferred, tests accessibility
const button = screen.getByRole('button', { name: /submit/i });
const heading = screen.getByRole('heading', { level: 1 });
const textbox = screen.getByRole('textbox', { name: /username/i });
// getByLabelText - Great for form inputs
const input = screen.getByLabelText(/email address/i);
// getByPlaceholderText - When label isn't visible
const search = screen.getByPlaceholderText(/search.../i);
// getByText - Find by visible text content
const error = screen.getByText(/invalid email/i);
2. Semantic Queries
// getByAltText - Images with alt text
const logo = screen.getByAltText(/company logo/i);
// getByTitle - Elements with title attribute
const tooltip = screen.getByTitle(/help/i);
3. Test ID Queries (Last Resort)
// getByTestId - Only when other queries don't work
const custom = screen.getByTestId('custom-element');
// In component:
<div data-testid="custom-element">Content</div>
Warning: Avoid getByTestId when possible. It doesn't test how users interact with your app and couples tests to implementation.
Query Variants
Each query comes in three variants:
// getBy* - Throws error if not found (synchronous)
const button = screen.getByRole('button');
// queryBy* - Returns null if not found (for asserting non-existence)
const error = screen.queryByText(/error/i);
expect(error).not.toBeInTheDocument();
// findBy* - Returns promise, waits for element (async)
const async = await screen.findByText(/loading complete/i);
Testing User Interactions
Use @testing-library/user-event to simulate user actions:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('submits form with user credentials', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
// Type into inputs
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
await user.type(emailInput, 'user@example.com');
await user.type(passwordInput, 'password123');
// Click submit button
const submitButton = screen.getByRole('button', { name: /login/i });
await user.click(submitButton);
// Assert form was submitted
expect(onSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123'
});
});
Note: Always use await with user-event actions. They return promises to properly simulate asynchronous user behavior.
Common User Events
const user = userEvent.setup();
// Typing
await user.type(input, 'Hello World');
await user.clear(input); // Clear input value
// Clicking
await user.click(button);
await user.dblClick(element);
await user.tripleClick(element);
// Hovering
await user.hover(element);
await user.unhover(element);
// Keyboard
await user.keyboard('{Enter}');
await user.keyboard('{Shift>}A{/Shift}'); // Shift+A
// Selection
await user.selectOptions(select, ['option1', 'option2']);
await user.deselectOptions(select, 'option1');
// File upload
const file = new File(['content'], 'test.png', { type: 'image/png' });
await user.upload(fileInput, file);
Testing Asynchronous Behavior
Use waitFor and findBy* queries for async operations:
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
test('loads and displays user data', async () => {
render(<UserProfile userId="123" />);
// Initially shows loading state
expect(screen.getByText(/loading.../i)).toBeInTheDocument();
// Wait for user data to appear (findBy returns promise)
const userName = await screen.findByText(/john doe/i);
expect(userName).toBeInTheDocument();
// Loading indicator should be gone
expect(screen.queryByText(/loading.../i)).not.toBeInTheDocument();
});
test('handles API errors', async () => {
// Mock API to return error
jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('API Error'));
render(<UserProfile userId="123" />);
// Wait for error message
await waitFor(() => {
expect(screen.getByText(/failed to load user/i)).toBeInTheDocument();
});
});
Tip: Prefer findBy* queries over getBy* + waitFor when possible. It's more concise and readable.
Testing Accessibility
React Testing Library encourages accessible components by prioritizing role-based queries:
test('form is accessible', () => {
render(<ContactForm />);
// All inputs should have labels
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/message/i)).toBeInTheDocument();
// Button should have accessible name
expect(screen.getByRole('button', { name: /send/i })).toBeInTheDocument();
// Check ARIA attributes
const emailInput = screen.getByLabelText(/email/i);
expect(emailInput).toHaveAttribute('type', 'email');
expect(emailInput).toHaveAttribute('required');
});
test('error messages are announced to screen readers', async () => {
const user = userEvent.setup();
render(<ContactForm />);
const submitButton = screen.getByRole('button', { name: /send/i });
await user.click(submitButton);
// Error should have role="alert" for screen readers
const error = screen.getByRole('alert');
expect(error).toHaveTextContent(/please fill in all fields/i);
});
Mocking Props and Functions
Use Jest mocks to test component behavior in isolation:
test('calls onDelete when delete button is clicked', async () => {
const user = userEvent.setup();
const onDelete = jest.fn();
render(<TaskItem task={{ id: 1, title: 'Test' }} onDelete={onDelete} />);
const deleteButton = screen.getByRole('button', { name: /delete/i });
await user.click(deleteButton);
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onDelete).toHaveBeenCalledWith(1);
});
test('disables button when loading', () => {
render(<SubmitButton loading={true} />);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(button).toHaveTextContent(/loading.../i);
});
Testing Conditional Rendering
test('shows success message after submission', async () => {
const user = userEvent.setup();
render(<NewsletterForm />);
// Initially no success message
expect(screen.queryByText(/thank you/i)).not.toBeInTheDocument();
// Submit form
const emailInput = screen.getByLabelText(/email/i);
await user.type(emailInput, 'test@example.com');
await user.click(screen.getByRole('button', { name: /subscribe/i }));
// Success message appears
expect(await screen.findByText(/thank you/i)).toBeInTheDocument();
// Form is hidden
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
});
Debugging Tests
React Testing Library provides helpful debugging utilities:
import { render, screen } from '@testing-library/react';
test('debug example', () => {
render(<MyComponent />);
// Print entire DOM
screen.debug();
// Print specific element
const button = screen.getByRole('button');
screen.debug(button);
// Print with options
screen.debug(undefined, 20000); // Increase character limit
// Log accessible roles
screen.logTestingPlaygroundURL();
});
Exercise:
Create a SearchFilter component and write comprehensive tests:
- Test that the search input is rendered and accessible
- Test that typing updates the input value
- Test that clicking the search button calls an
onSearch callback with the query
- Test that pressing Enter also triggers the search
- Test that the clear button resets the input and calls
onSearch with empty string
- Test that the search button is disabled when input is empty
- Test error state when search query is too short (<3 characters)
Focus on accessibility: use proper labels, ARIA attributes, and role-based queries.
Best Practices
- Query priority: getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId
- Use user-event: Prefer
userEvent over fireEvent for realistic interactions
- Test behavior, not implementation: Don't test state or props directly
- Avoid waitFor when possible: Use
findBy* queries instead
- Use realistic data: Test with data that matches production scenarios
- One assertion per test: Keep tests focused and easy to debug
- Descriptive test names: Test names should explain what and why, not how
Common Mistakes to Avoid
Warning:
- Don't query by class names or IDs - use accessible queries
- Don't test implementation details like component state
- Don't use
container.querySelector() - use screen queries
- Don't forget
await with async queries and user events
- Don't use
waitFor for everything - prefer findBy* queries
In the next lesson, we'll explore testing React Hooks and Context, which require special testing techniques.