React Testing Library: Unit Tests, Integration Tests, and E2E with...
React Testing Library (RTL) is the standard for testing React components. It encourages testing from the user’s perspective — finding elements by accessibility attributes and verifying behavior rather than implementation details.
Setting Up React Testing Library
Create React App includes RTL by default. For manual setup:
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdomConfigure the test setup file to import @testing-library/jest-dom for custom matchers like toBeInTheDocument().
Testing Async Behavior
React Testing Library provides utilities for testing asynchronous behavior. Use waitFor and findBy* queries (which return promises) to wait for elements to appear after state updates and API calls. Mock API calls with MSW (Mock Service Worker) for the most realistic tests — MSW intercepts network requests at the service worker level, so your application code does not need any modification for testing. For timers and animations, use jest.useFakeTimers() to control time in tests. Always clean up after each test to prevent test pollution and flaky results.
Core Concepts
Queries
RTL queries find elements in the DOM. Prioritize queries by accessibility:
getByRole— best for semantic elements (buttons, headings, links)getByLabelText— form inputs with labelsgetByPlaceholderText— inputs with placeholdersgetByText— non-interactive elements (paragraphs, divs)getByDisplayValue— form elements with current valuesgetByTitle— elements with title attributesgetByTestId— last resort (usedata-testid)
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Greeting from './Greeting';
test('renders greeting and responds to button click', async () => {
render(<Greeting name="Alice" />);
expect(screen.getByRole('heading')).toHaveTextContent('Hello, Alice!');
const button = screen.getByRole('button', { name: /change name/i });
await userEvent.click(button);
expect(screen.getByText('Hello, Bob!')).toBeInTheDocument();
---);Accessibility-First Testing
React Testing Library encourages testing from the user’s perspective. Use getByRole, getByLabelText, and getByText instead of test IDs when possible. This ensures your tests verify that the application works for actual users using assistive technologies. The jest-axe extension adds automated accessibility assertions to catch common violations during testing.
Component Testing Patterns
Testing Forms
test('submits form with user input', async () => {
const onSubmit = jest.fn();
render(<MyForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText(/name/i), 'Alice');
await userEvent.type(screen.getByLabelText(/email/i), 'alice@example.com');
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(onSubmit).toHaveBeenCalledWith({
name: 'Alice',
email: 'alice@example.com',
});
---);Testing API Calls with MSW
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('/api/user', () => {
return HttpResponse.json({ name: 'Alice' });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('loads and displays user', async () => {
render(<UserProfile />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
---);Mocking API Calls
For simpler cases, mock fetch directly or use jest.spyOn on your API module:
jest.spyOn(api, 'fetchUser').mockResolvedValue({ name: 'Alice' });MSW is preferred because it mocks at the network level, so your code needs zero modification for testing.
Integration Testing
Integration tests verify that multiple components work together. Render a page-level component and test user flows:
test('complete checkout flow', async () => {
render(<CheckoutPage />);
// Add item to cart
await userEvent.click(screen.getByRole('button', { name: /add to cart/i }));
expect(screen.getByText(/cart \(1\)/i)).toBeInTheDocument();
// Proceed to checkout
await userEvent.click(screen.getByRole('link', { name: /checkout/i }));
expect(screen.getByRole('heading', { name: /shipping/i })).toBeInTheDocument();
---);E2E Testing with Cypress
Cypress runs tests in a real browser for end-to-end testing:
describe('Login Flow', () => {
it('logs in successfully', () => {
cy.visit('/login');
cy.get('[data-testid="email"]').type('user@example.com');
cy.get('[data-testid="password"]').type('password123');
cy.get('[data-testid="submit"]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome back').should('be.visible');
});
---);Component Testing in Cypress
Cypress also supports component testing with the same API:
import Greeting from './Greeting';
it('renders greeting', () => {
cy.mount(<Greeting name="Alice" />);
cy.contains('Hello, Alice!').should('be.visible');
---);Integration Testing Flows
Integration tests verify that multiple components work together correctly. Render a page-level component with all its child components and real providers (router, theme, data). Test complete user flows: enter text in a form, submit it, and verify the success message appears. Use waitFor and findBy* queries to handle async transitions. For navigation flows, wrap the component in MemoryRouter and assert on the resulting URL. Integration tests are more valuable than unit tests for catching real bugs because they exercise the actual interactions between components.
Best Practices
- Test behavior, not implementation — avoid testing internal state or private methods
- Use
screen.getByRole()as your primary query — it matches accessibility - Use
userEventoverfireEvent— it more accurately simulates real user interactions - Write tests that read like a user story
- Keep tests isolated — each test should set up and tear down its own state
- Aim for 70-80% test coverage on critical paths, not 100%
- Run tests in CI with
--ci --coverageflags
Testing Accessibility
RTL integrates with jest-axe and @axe-core/react for automated accessibility testing. Add expect(await axe(container)).toHaveNoViolations() to your test to catch common accessibility issues like missing alt text, insufficient color contrast, and incorrect ARIA attributes. For interactive elements, verify keyboard navigation: tab through focusable elements with userEvent.tab() and assert focus styles. Test screen reader behavior by checking that dynamic content updates are announced via aria-live regions. Accessibility testing ensures your application works for all users and avoids legal compliance issues.
FAQ
Should I use enzyme or React Testing Library?
RTL is the recommended choice — it encourages testing from the user’s perspective rather than testing implementation details. Enzyme allows shallow rendering and internal state inspection, which leads to brittle tests. React team and community have standardized on RTL.
How do I test components that use context providers?
Wrap the component in a test provider: render(<ThemeProvider theme="dark"><MyComponent /></ThemeProvider>). Create a custom render function that wraps components with all needed providers, exported from a test utils file.
What is the difference between getBy, queryBy, and findBy?
getBy* throws if the element is not found (asserts existence). queryBy* returns null if not found (checks absence). findBy* returns a promise that resolves when the element appears (async). Use getBy for elements that should exist, queryBy for elements that should not exist, and findBy for elements that appear after async operations.
Mocking API Calls in Tests
Use MSW (Mock Service Worker) for the most realistic API mocking — it intercepts network requests at the service worker level, so your app code needs no modification. Define handlers that match URL patterns and return mock data. For simpler cases, Jest’s jest.spyOn can mock fetch or axios calls directly. Always test the loading, success, and error states of your data-fetching components. Wait for each state to appear using waitFor or findBy* queries. Reset mocks between tests with afterEach to prevent test pollution.
Custom Render Functions
Create a custom render function that wraps components with all required providers: function renderWithProviders(ui, { preloadedState, ...renderOptions }). Include providers for routing (MemoryRouter), state management (Redux Provider or Context), theming, and data fetching (MockedProvider for Apollo). Export from a test-utils.jsx file and import instead of RTL’s render in your tests. This eliminates boilerplate repetition and ensures all tests use consistent provider setup. For route-dependent tests, configure the initial route with MemoryRouter’s initialEntries prop.
Test Coverage Strategies
Focus test coverage on user-facing functionality and critical paths rather than implementation details. Write unit tests for isolated pure functions and custom hooks. Write integration tests for feature workflows — form submission, list filtering, navigation. Write E2E tests for critical user journeys — signup, checkout, search. Use code coverage tools (Istanbul, c8) to identify untested code paths. Aim for high coverage on business logic and data transformation layers. UI component coverage for edge cases (empty states, error states, loading states) catches real user-facing bugs.
E2E Testing with Cypress
Cypress provides end-to-end testing for complete user workflows. Install with npm install cypress --save-dev. Write tests that simulate real user journeys: visit a page, interact with elements, and assert on the resulting UI state. Use cy.intercept() to stub network requests at the Cypress level. Cypress automatically waits for elements and assertions, reducing flaky tests. Run in CI with cypress run (headless mode). E2E tests complement RTL unit/integration tests by verifying that the deployed application works correctly in a real browser environment.
Snapshot Testing with RTL
Snapshot tests capture the rendered output of a component and compare it to a stored reference. Use expect(container).toMatchSnapshot() to create a snapshot. Update snapshots with --updateSnapshot flag when component changes are intentional. Snapshot tests are most valuable for presentational components with complex, stable output. For interactive components, prefer assertion-based tests that verify behavior. Keep snapshots focused — snapshot an entire page causes massive diffs for minor changes. Review snapshot diffs carefully in code reviews to catch unintended changes.
Testing Custom Hooks
Test React hooks in isolation using renderHook from @testing-library/react-hooks (now part of RTL). renderHook(() => useMyHook(initialProps)) returns result.current — the hook’s return value. Test initial state, state changes via act(), and cleanup on unmount. For hooks that depend on context, wrap in a provider using wrapper option. Test error states by configuring the hook with invalid inputs. Hook tests are faster than component tests and provide direct coverage for your reusable logic.
How do I test error boundaries?
Render a component that throws inside the error boundary. Use jest.spyOn(console, 'error').mockImplementation() to suppress React’s error logging. Assert that the fallback UI renders using screen.getByRole('heading') or screen.getByText().
See also: React Native Guide: Mobile App Development with Ja.
See also: React Server Components: Streaming, Suspense, and .