React Testing Library (RTL) is a lightweight testing utility for React components, built on top of DOM Testing Library . It provides utility functions that encourage better testing practices by focusing on how users interact with your application rather than implementation details.
Key Features:
- User-Centric Testing: Queries the DOM in the same way users would (by label text, role, etc.)
- Lightweight: Thin layer on top of
react-domandreact-dom/test-utils - Framework Agnostic: Works with Jest, Mocha, Vitest, or any test runner
- Accessibility Focused: Encourages building more accessible applications
- Maintainable Tests: Tests break only when functionality changes, not implementation
Why React Testing Library?
- Write tests that give confidence your app works for real users
- Avoid testing implementation details that change frequently
- Promote accessible component design
- Industry standard with widespread adoption
#2. Philosophy: Testing Behavior, Not Implementation
The primary guiding principle of React Testing Library comes from its creator, Kent C. Dodds:
“The more your tests resemble the way your software is used, the more confidence they can give you.”
#What This Means in Practice
Test what users experience:
- Users see rendered output, not component state
- Users click buttons, not call event handlers directly
- Users read text content, not prop values
Good Example (Behavior-Driven):
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
test('increments count when button is clicked', () => {
render(<Counter initialCount={0} />);
// Find button by its visible text (how a user finds it)
const button = screen.getByRole('button', { name: /increment/i });
// Simulate user click
fireEvent.click(button);
// Verify what user sees
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
Bad Example (Testing Implementation):
// Don't do this - tests implementation details
test('increments count when button is clicked (bad)', () => {
const { container } = render(<Counter initialCount={0} />);
// Testing internal state (user doesn't see this)
expect(component.state().count).toBe(0); // ❌ Implementation detail
// Testing by class name (user doesn't care about CSS classes)
container.querySelector('.increment-btn').click(); // ❌ Brittle
// Testing props passed to child (user doesn't see this)
expect(childComponent.prop('onClick')).toHaveBeenCalled(); // ❌ Too internal
});
Benefits of This Philosophy :
- Tests survive refactoring: Rename state variables, change internal logic, tests still pass
- Better confidence: If tests pass, real users will likely succeed
- Accessibility by default: Using accessible queries improves your app for everyone
- Simpler tests: No need to understand component internals
#3. Installation and Setup
#Basic Installation
For a project created with Create React App, React Testing Library is included by default . For manual setup:
npm install --save-dev @testing-library/react @testing-library/jest-dom
If you’re using a test runner other than Jest, install it separately:
npm install --save-dev jest
#TypeScript Setup
For TypeScript projects, install the type definitions:
npm install --save-dev @types/react @types/react-dom @testing-library/jest-dom
#Jest Configuration
For Jest 27+ , you need to set the test environment to jsdom :
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
};
For Jest 28+ , install the jsdom environment separately :
npm install --save-dev jest-environment-jsdom
Create a setup file (src/setupTests.js):
import '@testing-library/jest-dom';
This adds helpful assertions like toBeInTheDocument(), toHaveTextContent(), etc.
#With Vite and Vitest
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/setupTests.js'],
},
});
#4. Core Concepts
#Render Function
The render function renders a React component into a virtual DOM container .
import { render } from '@testing-library/react';
import MyComponent from './MyComponent';
test('renders component', () => {
const { container, unmount, rerender } = render(<MyComponent />);
// container is the DOM node containing the rendered component
expect(container).toBeInTheDocument();
});
Render Options:
// With props
render(<MyComponent title="Hello" />);
// With wrapper (for providers)
render(<MyComponent />, {
wrapper: ({ children }) => <ThemeProvider theme="dark">{children}</ThemeProvider>,
});
#Screen Object
The screen object provides access to query methods bound to document.body . It’s the recommended way to query elements.
import { render, screen } from '@testing-library/react';
test('using screen', () => {
render(<MyComponent />);
// No need to destructure from render
const heading = screen.getByRole('heading');
expect(heading).toBeInTheDocument();
// Debug what's in the document
screen.debug();
});
#Queries and Their Priority
React Testing Library provides different query methods, prioritized by how users find elements .
Priority Order (highest to lowest):
-
Queries Accessible to Everyone :
getByRole– Find by ARIA role (button, heading, etc.)getByLabelText– Find form inputs by their<label>getByPlaceholderText– Find by placeholder (less preferred)getByText– Find by visible text contentgetByDisplayValue– Find form elements by current value
-
Semantic Queries :
getByAltText– Find images by alt textgetByTitle– Find elements by title attribute
-
Test IDs (last resort) :
getByTestId– Find bydata-testidattribute
#FireEvent vs UserEvent
FireEvent :
- Synchronous, lower-level DOM event dispatcher
- Fires exactly the event you specify
- Faster but less realistic
import { fireEvent, screen } from '@testing-library/react';
fireEvent.click(screen.getByRole('button'));
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'John' },
});
UserEvent (recommended) :
- Higher-level simulation of user interactions
- Fires multiple events like a real user (hover, focus, etc.)
- More realistic, catches edge cases
- Requires installation:
npm install --save-dev @testing-library/user-event
import userEvent from '@testing-library/user-event';
test('userEvent example', async () => {
const user = userEvent.setup();
await user.click(screen.getByRole('button'));
await user.type(screen.getByLabelText('Name'), 'John');
await user.keyboard('{Enter}');
});
#Act and Async Utilities
The act() function ensures all updates related to a component are completed before assertions are made .
import { act } from 'react-dom/test-utils';
// Note: React Testing Library wraps most operations in act() automatically
// Manual act example (rarely needed with RTL)
act(() => {
render(<Component />);
});
When act warnings appear :
// Problem: State update after event
test('problematic test', () => {
render(<Counter />);
fireEvent.click(screen.getByText('Increment'));
// Warning: update not wrapped in act()
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
// Solution: RTL's findBy queries auto-wait
test('fixed with findBy', async () => {
render(<Counter />);
fireEvent.click(screen.getByText('Increment'));
expect(await screen.findByText('Count: 1')).toBeInTheDocument();
});
#5. Query Methods Deep Dive
#getBy* Queries
getBy* methods return the matching node, or throw an error if not found .
import { screen } from '@testing-library/react';
// These throw if element not found
const button = screen.getByRole('button');
const heading = screen.getByText('Welcome');
const input = screen.getByLabelText('Email');
const image = screen.getByAltText('Profile');
const byTestId = screen.getByTestId('submit-button');
Use when: Element MUST exist, test should fail if not found.
#findBy* Queries
findBy* methods return a Promise that resolves when the element is found (up to 1000ms default) . Perfect for async operations.
test('async element appears', async () => {
render(<AsyncComponent />);
// Waits for element to appear
const element = await screen.findByText('Loaded');
expect(element).toBeInTheDocument();
// With custom timeout
const slowElement = await screen.findByRole('dialog', {}, { timeout: 2000 });
});
Use when: Element appears after async operation (API call, timeout, animation).
#queryBy* Queries
queryBy* methods return the matching node or null if not found (no error thrown) .
test('element is not present', () => {
render(<Component />);
// Returns null, doesn't throw
const errorMessage = screen.queryByText('Error');
expect(errorMessage).not.toBeInTheDocument();
});
Use when: Testing that an element DOES NOT exist.
#Multiple Elements Queries
For finding multiple elements, use getAllBy*, findAllBy*, queryAllBy* .
test('multiple elements', async () => {
render(<List items={['a', 'b', 'c']} />);
// Returns array of matches
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(3);
// Async version
const asyncItems = await screen.findAllByText(/item/);
expect(asyncItems.length).toBeGreaterThan(0);
});
#Assertions with Jest-DOM
The @testing-library/jest-dom package provides custom matchers for DOM assertions .
import { screen } from '@testing-library/react';
// Element presence
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toBeDisabled();
// Text content
expect(element).toHaveTextContent('Hello');
expect(element).toHaveTextContent(/hello/i);
// Attributes
expect(element).toHaveAttribute('type', 'submit');
expect(element).toHaveClass('active');
expect(element).toHaveStyle('color: red');
// Form values
expect(input).toHaveValue('test');
expect(checkbox).toBeChecked();
expect(select).toHaveDisplayValue('Option 1');
#6. Testing User Interactions
#Click Events
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
test('increments counter on click', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
// Single click
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
// Double click
await user.dblClick(button);
expect(screen.getByText('Count: 2')).toBeInTheDocument();
});
#Form Input and Submission
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('submits form with user data', async () => {
const mockSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={mockSubmit} />);
// Fill form
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
// Submit
await user.click(screen.getByRole('button', { name: /submit/i }));
// Verify submission
expect(mockSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
});
});
#Keyboard Events
test('keyboard interactions', async () => {
const user = userEvent.setup();
render(<SearchInput />);
const input = screen.getByRole('textbox');
// Type text
await user.type(input, 'search term');
expect(input).toHaveValue('search term');
// Tab navigation
await user.tab();
expect(screen.getByRole('button')).toHaveFocus();
// Press specific keys
await user.keyboard('{Enter}');
await user.keyboard('{Escape}');
// Clear input
await user.clear(input);
});
#Hover and Focus
test('hover and focus states', async () => {
const user = userEvent.setup();
render<Tooltip />);
const button = screen.getByRole('button');
// Hover
await user.hover(button);
expect(screen.getByText('Tooltip text')).toBeVisible();
// Unhover
await user.unhover(button);
expect(screen.queryByText('Tooltip text')).not.toBeInTheDocument();
// Focus
await user.tab();
expect(button).toHaveFocus();
});
#7. Testing Asynchronous Behavior
#Waiting for Elements
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('waits for loading to complete', async () => {
render(<DataFetcher />);
// Method 1: findBy (recommended)
const data = await screen.findByText('Loaded data');
expect(data).toBeInTheDocument();
// Method 2: waitFor with getBy
await waitFor(() => {
expect(screen.getByText('Loaded data')).toBeInTheDocument();
});
// Method 3: waitForElementToBeRemoved
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
});
#Testing API Calls
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UserProfile from './UserProfile';
// Mock fetch or axios
global.fetch = jest.fn();
test('fetches and displays user data', async () => {
const mockUser = { name: 'John Doe', email: 'john@example.com' };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
render(<UserProfile userId="123" />);
// Loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Data loaded
expect(await screen.findByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
// Verify fetch was called correctly
expect(fetch).toHaveBeenCalledWith('/api/users/123');
});
test('handles API error', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
render(<UserProfile userId="123" />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
#Timers and Delays
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('handles setTimeout', async () => {
jest.useFakeTimers();
render<DelayedMessage />);
// Trigger delayed action
fireEvent.click(screen.getByText('Show message'));
// Fast-forward time
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.getByText('Delayed message')).toBeInTheDocument();
jest.useRealTimers();
});
#8. Advanced Testing Patterns
#Custom Render with Providers
For components that depend on context providers, create a custom render function .
// test-utils.jsx
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
const AllTheProviders = ({ children }) => {
return (
<ThemeProvider>
<AuthProvider>{children}</AuthProvider>
</ThemeProvider>
);
};
const customRender = (ui, options) => render(ui, { wrapper: AllTheProviders, ...options });
// re-export everything
export * from '@testing-library/react';
export { customRender as render };
// In test files
import { render, screen } from '../test-utils';
test('component with providers', () => {
render(<ComponentThatUsesTheme />);
expect(screen.getByText('Dark mode')).toBeInTheDocument();
});
#Custom Queries
Create custom queries for specific use cases .
// custom-queries.js
import { queryHelpers, buildQueries } from '@testing-library/react';
const queryAllByDataCy = (container, id) =>
queryHelpers.queryAllByAttribute('data-cy', container, id);
const getMultipleError = (c, dataCyValue) =>
`Found multiple elements with data-cy="${dataCyValue}"`;
const getMissingError = (c, dataCyValue) => `Unable to find element with data-cy="${dataCyValue}"`;
const [queryByDataCy, getAllByDataCy, getByDataCy, findAllByDataCy, findByDataCy] = buildQueries(
queryAllByDataCy,
getMultipleError,
getMissingError
);
export { queryByDataCy, queryAllByDataCy, getByDataCy, findAllByDataCy, findByDataCy };
#Testing Custom Hooks
Use @testing-library/react-hooks for hook testing .
npm install --save-dev @testing-library/react-hooks
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';
test('useCounter increments', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
#Testing Context and State Management
// Testing Context consumer
test('uses context value', () => {
const testValue = { user: 'testuser' };
render(
<UserContext.Provider value={testValue}>
<UserProfile />
</UserContext.Provider>
);
expect(screen.getByText('testuser')).toBeInTheDocument();
});
// Testing Redux connected component
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import reducer from './reducer';
test('redux component', () => {
const store = createStore(reducer, { user: 'test' });
render(
<Provider store={store}>
<UserComponent />
</Provider>
);
expect(screen.getByText('test')).toBeInTheDocument();
});
#9. React Testing Library vs Enzyme
#Philosophical Differences
| Aspect | React Testing Library | Enzyme |
|---|---|---|
| Testing Approach | User behavior focus | Component internals focus |
| DOM Querying | Like user would | Traverse component output |
| Rendering Strategies | Full DOM only | Shallow, full, static |
| Learning Curve | Gentle, intuitive | Steeper, more API |
| React 18 Support | Full support | Limited (community adapter) |
| Philosophy | ”Test like a user" | "Test like a developer” |
#API Comparison
RTL Example:
import { render, screen, fireEvent } from '@testing-library/react';
test('RTL test', () => {
render(<Button>Click</Button>);
fireEvent.click(screen.getByText('Click'));
expect(screen.getByText('Clicked')).toBeInTheDocument();
});
Enzyme Example:
import { shallow } from 'enzyme';
test('Enzyme test', () => {
const wrapper = shallow(<Button>Click</Button>);
wrapper.find('button').simulate('click');
expect(wrapper.find('div').text()).toBe('Clicked');
});
#When to Use Which
Choose React Testing Library when:
- You want tests that survive refactoring
- You care about accessibility
- You’re starting a new project
- You want industry-standard practices
Choose Enzyme when (legacy considerations):
- Maintaining large existing Enzyme test suite
- Need shallow rendering for performance (though RTL encourages integration tests)
- Testing complex component internals (though this is often an anti-pattern)
#Migration from Enzyme to RTL
HubSpot successfully migrated over 76,000 tests from Enzyme to React Testing Library over 2.5 years . Key lessons:
Migration Strategy :
- Measure: Instrument Enzyme usage to understand scope
- Educate: Create guides and documentation
- Pilot: Start with smaller packages
- Scale: Develop tooling and provide direct support
- Celebrate: Track progress and share wins
Migration Tips:
- Replace
shallowwith full rendering (embrace integration tests) - Replace
instance()andstate()access with DOM assertions - Use
userEventinstead of simulating events directly - Leverage
findByqueries for async behavior - Add
data-testidas last resort for hard-to-query elements
#10. Best Practices
#The Testing Trophy
The testing trophy represents a balanced testing strategy:
/\
/ E2E \
/ (few) \
/-----------\
/ Integration \
/ (some) \
/-----------------\
/ Unit \
/(most, but not too many)/
----------------------
- Static Analysis (TypeScript, ESLint) – Catch errors early
- Unit Tests – Test individual functions and utilities
- Integration Tests – Test component interactions (sweet spot for RTL)
- E2E Tests – Test critical user paths (Playwright, Cypress)
#Arrange-Act-Assert Pattern
Structure every test with three clear sections:
test('should show error for invalid email', () => {
// Arrange - setup
render(<LoginForm />);
const emailInput = screen.getByLabelText(/email/i);
const submitButton = screen.getByRole('button', { name: /submit/i });
// Act - perform action
fireEvent.change(emailInput, { target: { value: 'invalid' } });
fireEvent.click(submitButton);
// Assert - verify result
const errorMessage = screen.getByText(/invalid email/i);
expect(errorMessage).toBeInTheDocument();
});
#Query Priority
Follow this priority order for queries:
-
getByRole – Most preferred (supports name option)
screen.getByRole('button', { name: /submit/i }); screen.getByRole('heading', { level: 1 }); -
getByLabelText – For form fields
screen.getByLabelText(/email address/i); -
getByPlaceholderText – (only if label not available)
-
getByText – For non-interactive elements
-
getByDisplayValue – For form current values
-
getByAltText – For images
-
getByTitle – For title attributes
-
getByTestId – Last resort
#Mocking Boundaries
Mock at the application boundaries, not internally:
// ✅ GOOD: Mock external API
jest.mock('../api', () => ({
fetchUser: jest.fn().mockResolvedValue({ name: 'Test' }),
}));
// ❌ BAD: Mock internal module
jest.mock('../utils/helpers'); // Don't mock your own utilities
jest.mock('./ChildComponent'); // Don't mock child components
// Instead, test the integration of parent + child together
#Test Isolation
Each test should be independent :
beforeEach(() => {
// Clean up before each test
jest.clearAllMocks();
});
afterEach(() => {
// Additional cleanup if needed
cleanup(); // render() automatically cleans up
});
#11. Integration Testing
Integration tests verify that multiple parts work together .
#Testing Component Composition
// ShoppingCart.jsx
function ShoppingCart() {
const [items, setItems] = useState([]);
const addItem = (product) => {
setItems([...items, product]);
};
return (
<div>
<ProductList onAddToCart={addItem} />
<Cart items={items} />
</div>
);
}
// ShoppingCart.test.jsx
test('adds product to cart', async () => {
const user = userEvent.setup();
render(<ShoppingCart />);
// Find and click "Add to Cart" on a product
const addButton = screen.getByRole('button', {
name: /add.*product 1/i,
});
await user.click(addButton);
// Verify cart updated
expect(screen.getByText(/cart.*1 item/i)).toBeInTheDocument();
expect(screen.getByText(/product 1/i)).toBeInTheDocument();
});
#Testing Routes and Navigation
import { MemoryRouter } from 'react-router-dom';
test('navigates to about page', async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>
);
// Click navigation link
await user.click(screen.getByRole('link', { name: /about/i }));
// Verify new page content
expect(await screen.findByRole('heading', { name: /about/i })).toBeInTheDocument();
});
#Testing Forms End-to-End
test('complete form submission flow', async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn();
render(<RegistrationForm onSubmit={mockSubmit} />);
// Fill all fields
await user.type(screen.getByLabelText(/first name/i), 'John');
await user.type(screen.getByLabelText(/last name/i), 'Doe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
// Check terms checkbox
await user.click(screen.getByLabelText(/terms/i));
// Submit
await user.click(screen.getByRole('button', { name: /register/i }));
// Verify submission data
expect(mockSubmit).toHaveBeenCalledWith({
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
password: 'password123',
acceptedTerms: true,
});
// Verify success message or redirect
expect(await screen.findByText(/registration successful/i)).toBeInTheDocument();
});
#12. Real-World Project: Todo Application
#Project Overview
Build and test a complete Todo application with:
- Add new todos
- Mark todos complete
- Delete todos
- Filter todos (all/active/completed)
- Local storage persistence
#Component Structure
src/
components/
TodoApp.jsx
TodoList.jsx
TodoItem.jsx
AddTodo.jsx
FilterButtons.jsx
hooks/
useTodos.js
__tests__/
TodoApp.test.jsx
useTodos.test.js
#Testing Todo Creation
// TodoApp.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoApp from '../TodoApp';
test('adds a new todo', async () => {
const user = userEvent.setup();
render(<TodoApp />);
// Find input and add button
const input = screen.getByPlaceholderText(/add a new todo/i);
const addButton = screen.getByRole('button', { name: /add/i });
// Type and submit
await user.type(input, 'Learn React Testing Library');
await user.click(addButton);
// Verify todo appears
expect(screen.getByText('Learn React Testing Library')).toBeInTheDocument();
// Input should be cleared
expect(input).toHaveValue('');
});
test('prevents adding empty todos', async () => {
const user = userEvent.setup();
render(<TodoApp />);
const addButton = screen.getByRole('button', { name: /add/i });
await user.click(addButton);
// No new todos should appear
const todos = screen.queryAllByRole('listitem');
expect(todos).toHaveLength(0); // Assuming initial empty list
});
#Testing Todo Completion
test('toggles todo completion', async () => {
const user = userEvent.setup();
render(<TodoApp />);
// Add a todo first
const input = screen.getByPlaceholderText(/add a new todo/i);
const addButton = screen.getByRole('button', { name: /add/i });
await user.type(input, 'Test todo');
await user.click(addButton);
// Find the checkbox and click it
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox);
// Todo should be marked complete (has strikethrough or different style)
expect(checkbox).toBeChecked();
// Todo text might have completed class
const todoText = screen.getByText('Test todo');
expect(todoText).toHaveClass('completed'); // Assuming class-based styling
// Toggle back
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
});
test('deletes a todo', async () => {
const user = userEvent.setup();
render(<TodoApp />);
// Add a todo
await user.type(screen.getByPlaceholderText(/add/i), 'To be deleted');
await user.click(screen.getByRole('button', { name: /add/i }));
// Delete button should be available
const deleteButton = screen.getByRole('button', { name: /delete/i });
await user.click(deleteButton);
// Todo should be gone
expect(screen.queryByText('To be deleted')).not.toBeInTheDocument();
});
#Testing Filtering
test('filters todos by status', async () => {
const user = userEvent.setup();
render(<TodoApp />);
// Add multiple todos
const input = screen.getByPlaceholderText(/add/i);
const addButton = screen.getByRole('button', { name: /add/i });
await user.type(input, 'Active todo 1');
await user.click(addButton);
await user.type(input, 'Active todo 2');
await user.click(addButton);
await user.type(input, 'Completed todo');
await user.click(addButton);
// Complete one todo
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[2]); // Complete the third todo
// Test "Active" filter
await user.click(screen.getByRole('button', { name: /active/i }));
expect(screen.getByText('Active todo 1')).toBeInTheDocument();
expect(screen.getByText('Active todo 2')).toBeInTheDocument();
expect(screen.queryByText('Completed todo')).not.toBeInTheDocument();
// Test "Completed" filter
await user.click(screen.getByRole('button', { name: /completed/i }));
expect(screen.queryByText('Active todo 1')).not.toBeInTheDocument();
expect(screen.queryByText('Active todo 2')).not.toBeInTheDocument();
expect(screen.getByText('Completed todo')).toBeInTheDocument();
// Test "All" filter
await user.click(screen.getByRole('button', { name: /all/i }));
expect(screen.getByText('Active todo 1')).toBeInTheDocument();
expect(screen.getByText('Active todo 2')).toBeInTheDocument();
expect(screen.getByText('Completed todo')).toBeInTheDocument();
});
#Testing Local Storage Persistence
// Mock localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: jest.fn((key) => store[key] || null),
setItem: jest.fn((key, value) => {
store[key] = value.toString();
}),
clear: jest.fn(() => {
store = {};
}),
removeItem: jest.fn((key) => {
delete store[key];
}),
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
test('persists todos to localStorage', async () => {
const user = userEvent.setup();
localStorageMock.clear();
localStorageMock.setItem.mockClear();
render(<TodoApp />);
// Add a todo
await user.type(screen.getByPlaceholderText(/add/i), 'Persisted todo');
await user.click(screen.getByRole('button', { name: /add/i }));
// Should save to localStorage
expect(localStorageMock.setItem).toHaveBeenCalled();
const savedData = JSON.parse(localStorageMock.setItem.mock.calls[0][1]);
expect(savedData).toContainEqual(expect.objectContaining({ text: 'Persisted todo' }));
});
test('loads todos from localStorage', () => {
// Pre-populate localStorage
const initialTodos = [
{ id: '1', text: 'Saved todo 1', completed: false },
{ id: '2', text: 'Saved todo 2', completed: true },
];
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(initialTodos));
render(<TodoApp />);
// Todos should load from storage
expect(screen.getByText('Saved todo 1')).toBeInTheDocument();
expect(screen.getByText('Saved todo 2')).toBeInTheDocument();
});
#13. Real-World Project: E-Commerce Product Page
#Project Overview
Test a product page with:
- Product display (image, name, price)
- Quantity selector
- Add to cart functionality
- Related products
- API integration
#Testing Product Display
// ProductPage.test.jsx
import { render, screen } from '@testing-library/react';
import ProductPage from '../ProductPage';
const mockProduct = {
id: 1,
name: 'Test Product',
price: 29.99,
description: 'This is a test product',
imageUrl: '/test-image.jpg',
};
test('displays product information correctly', () => {
render(<ProductPage product={mockProduct} />);
// Check product name
expect(screen.getByRole('heading', { name: 'Test Product' })).toBeInTheDocument();
// Check price
expect(screen.getByText('$29.99')).toBeInTheDocument();
// Check description
expect(screen.getByText('This is a test product')).toBeInTheDocument();
// Check image
const image = screen.getByRole('img', { name: /test product/i });
expect(image).toHaveAttribute('src', '/test-image.jpg');
});
test('shows loading state', () => {
render(<ProductPage isLoading={true} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
test('shows error state', () => {
render(<ProductPage error="Failed to load product" />);
expect(screen.getByText(/failed to load product/i)).toBeInTheDocument();
});
#Testing Quantity Selection
test('quantity selector works correctly', async () => {
const user = userEvent.setup();
render(<ProductPage product={mockProduct} />);
// Find quantity input
const quantityInput = screen.getByLabelText(/quantity/i);
expect(quantityInput).toHaveValue(1);
// Increment button
const incrementButton = screen.getByRole('button', { name: /\+/i });
await user.click(incrementButton);
expect(quantityInput).toHaveValue(2);
// Decrement button
const decrementButton = screen.getByRole('button', { name: /-/i });
await user.click(decrementButton);
expect(quantityInput).toHaveValue(1);
// Can't go below 1
await user.click(decrementButton);
expect(quantityInput).toHaveValue(1);
// Manual input
await user.clear(quantityInput);
await user.type(quantityInput, '5');
expect(quantityInput).toHaveValue(5);
// Invalid input handled
await user.clear(quantityInput);
await user.type(quantityInput, 'abc');
expect(quantityInput).toHaveValue(1); // Should revert or handle gracefully
});
#Testing Add to Cart
test('adds product to cart with correct quantity', async () => {
const user = userEvent.setup();
const mockAddToCart = jest.fn();
render(<ProductPage product={mockProduct} onAddToCart={mockAddToCart} />);
// Set quantity
const quantityInput = screen.getByLabelText(/quantity/i);
await user.clear(quantityInput);
await user.type(quantityInput, '3');
// Click add to cart
const addButton = screen.getByRole('button', { name: /add to cart/i });
await user.click(addButton);
// Verify callback
expect(mockAddToCart).toHaveBeenCalledWith(mockProduct.id, 3);
// Success feedback
expect(await screen.findByText(/added to cart/i)).toBeInTheDocument();
});
test('shows confirmation dialog for bulk quantities', async () => {
const user = userEvent.setup();
render(<ProductPage product={mockProduct} />);
// Set large quantity (over threshold)
const quantityInput = screen.getByLabelText(/quantity/i);
await user.clear(quantityInput);
await user.type(quantityInput, '100');
// Click add to cart
await user.click(screen.getByRole('button', { name: /add to cart/i }));
// Confirmation dialog should appear
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText(/confirm bulk order/i)).toBeInTheDocument();
// Confirm
await user.click(screen.getByRole('button', { name: /confirm/i }));
// Dialog should close
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
#Testing API Integration
// Mock API module
jest.mock('../api', () => ({
fetchProduct: jest.fn(),
addToCart: jest.fn(),
}));
import { fetchProduct, addToCart } from '../api';
import ProductPageContainer from '../ProductPageContainer';
test('fetches product on mount', async () => {
fetchProduct.mockResolvedValueOnce(mockProduct);
render(<ProductPageContainer productId="123" />);
// Loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Product loads
expect(await screen.findByText('Test Product')).toBeInTheDocument();
expect(fetchProduct).toHaveBeenCalledWith('123');
});
test('handles API error gracefully', async () => {
fetchProduct.mockRejectedValueOnce(new Error('Network error'));
render(<ProductPageContainer productId="123" />);
expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
// Retry functionality
fetchProduct.mockResolvedValueOnce(mockProduct);
await userEvent.click(screen.getByRole('button', { name: /retry/i }));
expect(await screen.findByText('Test Product')).toBeInTheDocument();
});
test('calls API when adding to cart', async () => {
const user = userEvent.setup();
addToCart.mockResolvedValueOnce({ success: true });
fetchProduct.mockResolvedValueOnce(mockProduct);
render(<ProductPageContainer productId="123" />);
// Wait for product to load
await screen.findByText('Test Product');
// Add to cart
await user.click(screen.getByRole('button', { name: /add to cart/i }));
expect(addToCart).toHaveBeenCalledWith('123', 1);
expect(await screen.findByText(/added successfully/i)).toBeInTheDocument();
});
This guide covers the essential aspects of React Testing Library from beginner to advanced. Practice by building and testing the real-world projects, and refer to the official documentation for deeper dives. Happy testing!