Skip to content
Home
React Testing Library: Unit Tests, Integration Tests, and E2E with...

React Testing Library: Unit Tests, Integration Tests, and E2E with...

React React 8 min read 1499 words Beginner ExcellentWiki Editorial Team

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-jsdom

Configure 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:

  1. getByRole — best for semantic elements (buttons, headings, links)
  2. getByLabelText — form inputs with labels
  3. getByPlaceholderText — inputs with placeholders
  4. getByText — non-interactive elements (paragraphs, divs)
  5. getByDisplayValue — form elements with current values
  6. getByTitle — elements with title attributes
  7. getByTestId — last resort (use data-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 userEvent over fireEvent — 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 --coverage flags

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 .

Section: React 1499 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top