React Testing Library: Unit and Integration Testing (2026)

React Testing Library (RTL) forces you to write tests that reflect how users actually interact with your app — not how components are implemented internally. The result is tests that survive refactors, catch real accessibility regressions, and give you genuine confidence in your UI. This guide covers RTL's philosophy, all query types, user-event v14, MSW v2 API mocking, and a complete login form test suite.

Table of Contents

RTL Philosophy: Test Behavior, Not Implementation

The guiding principle of RTL is the Testing Trophy: invest most in integration tests that exercise multiple components working together, less in isolated unit tests of single components, and very few in end-to-end tests. The key rule: The more your tests resemble the way your software is used, the more confidence they can give you.

Concretely this means:

  • Never test internal state. If a counter increments its internal count variable, test that the displayed number changes — not the variable value.
  • Never test component instance methods. Test what the user sees after they click a button.
  • Query elements the way assistive technologies do: by role, label, text — not by CSS class or component name.
Note: RTL deliberately does not expose a way to access component state or instance methods. This is a feature, not a limitation. If you find yourself wanting that access, you're testing implementation details.

Setup and Configuration

npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom
npm install --save-dev msw jest-environment-jsdom
// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  testEnvironment: 'jsdom',
  setupFilesAfterFramework: ['./src/setupTests.ts'],
  moduleNameMapper: {
    '\\.(css|less|scss)$': 'identity-obj-proxy',
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
};
export default config;

// src/setupTests.ts
import '@testing-library/jest-dom';

// Silence act() warnings for async state updates
global.IS_REACT_ACT_ENVIRONMENT = true;

Query Priority Guide

RTL provides many query functions. Use the highest-priority one that works for your situation — it produces the most resilient tests.

PriorityQueryWhen to Use
1 (highest)getByRoleAlmost everything: buttons, headings, inputs, links, checkboxes. Most robust.
2getByLabelTextForm fields associated with a label element.
3getByPlaceholderTextInputs where only a placeholder is available (prefer label).
4getByTextNon-interactive elements: paragraphs, spans, divs.
5getByDisplayValuePre-filled form inputs.
6getByAltTextImages and elements with an alt attribute.
7getByTitleElements with a title attribute.
8 (last resort)getByTestIdOnly when no semantic query works. Requires data-testid attribute.
// Good — uses role and accessible name
const submitBtn = screen.getByRole('button', { name: /submit/i });
const emailInput = screen.getByLabelText(/email address/i);

// Acceptable — text content
const heading = screen.getByRole('heading', { name: /welcome back/i });

// Last resort
const spinner = screen.getByTestId('loading-spinner');

// Query variants: get* throws, query* returns null, find* returns Promise
screen.queryByRole('alert'); // null if absent — good for asserting absence
await screen.findByText(/success/i); // waits up to 1000ms for element to appear
Pro Tip: Run screen.debug() when a query fails. It prints the current DOM so you can see what roles and labels are actually available. Use screen.debug(element) to print only a subtree.

Async Queries: findBy* and waitFor

findBy* queries are the simplest way to wait for elements that appear after async operations. waitFor is the escape hatch for complex async assertions.

// findBy* — polls until element appears (default timeout: 1000ms)
const successMsg = await screen.findByRole('alert', { name: /logged in/i });

// waitFor — for non-element assertions
await waitFor(() => {
  expect(mockOnSubmit).toHaveBeenCalledTimes(1);
});

// waitFor with multiple assertions (all run inside the same polling cycle)
await waitFor(() => {
  expect(screen.getByText('Welcome, Alice')).toBeInTheDocument();
  expect(screen.queryByText('Logging in...')).not.toBeInTheDocument();
});

// waitForElementToBeRemoved — cleaner than waitFor for disappearing elements
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
Note: Never use await new Promise(r => setTimeout(r, 500)) in tests. It makes tests slow and flaky. Use findBy* or waitFor which poll on a tight interval and exit as soon as the condition is met.

user-event v14

user-event v14 is a complete rewrite that simulates real browser events more accurately than fireEvent. Always call userEvent.setup() at the top of your test to get a properly initialized instance.

import userEvent from '@testing-library/user-event';

describe('SearchBox', () => {
  it('calls onSearch with the typed value', async () => {
    const user = userEvent.setup(); // important: call setup() once per test
    const onSearch = jest.fn();
    render(<SearchBox onSearch={onSearch} />);

    const input = screen.getByRole('textbox', { name: /search/i });

    await user.type(input, 'react hooks');
    expect(input).toHaveValue('react hooks');

    await user.keyboard('{Enter}');
    expect(onSearch).toHaveBeenCalledWith('react hooks');
  });

  it('clears the input on Escape', async () => {
    const user = userEvent.setup();
    render(<SearchBox onSearch={jest.fn()} />);
    const input = screen.getByRole('textbox', { name: /search/i });

    await user.type(input, 'hello');
    await user.keyboard('{Escape}');
    expect(input).toHaveValue('');
  });
});

MSW v2: Mocking APIs

Mock Service Worker (MSW) intercepts network requests at the service worker level — your components use the real fetch API and never know requests are mocked. MSW v2 uses a new http namespace instead of rest.

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.post('/api/auth/login', async ({ request }) => {
    const body = await request.json() as { email: string; password: string };

    if (body.email === 'test@example.com' && body.password === 'correct') {
      return HttpResponse.json({ token: 'fake-jwt', user: { name: 'Alice', role: 'admin' } });
    }
    return HttpResponse.json({ message: 'Invalid credentials' }, { status: 401 });
  }),

  http.get('/api/profile', ({ request }) => {
    const auth = request.headers.get('Authorization');
    if (!auth) return new HttpResponse(null, { status: 401 });
    return HttpResponse.json({ id: '1', name: 'Alice', email: 'test@example.com' });
  }),
];

// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);

// src/setupTests.ts (add these lines)
import { server } from './mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Testing Custom Hooks with renderHook

// hooks/__tests__/useFetch.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from '../useFetch';
import { server } from '../../mocks/server';
import { http, HttpResponse } from 'msw';

describe('useFetch', () => {
  it('returns data on success', async () => {
    server.use(
      http.get('/api/test', () => HttpResponse.json({ value: 42 }))
    );

    const { result } = renderHook(() => useFetch('/api/test'));

    expect(result.current.loading).toBe(true);
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.data).toEqual({ value: 42 });
    expect(result.current.error).toBeNull();
  });

  it('returns an error on HTTP failure', async () => {
    server.use(
      http.get('/api/test', () => new HttpResponse(null, { status: 500 }))
    );

    const { result } = renderHook(() => useFetch('/api/test'));
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.error).toBeInstanceOf(Error);
    expect(result.current.data).toBeNull();
  });
});

Full Login Form Test Suite

A complete example combining all the concepts: renders a login form, tests validation errors, successful login with MSW, and error handling.

// components/__tests__/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server } from '../../mocks/server';
import { http, HttpResponse } from 'msw';
import { LoginForm } from '../LoginForm';

const renderLoginForm = (onSuccess = jest.fn()) => {
  render(<LoginForm onSuccess={onSuccess} />);
  return {
    emailInput: screen.getByLabelText(/email/i),
    passwordInput: screen.getByLabelText(/password/i),
    submitBtn: screen.getByRole('button', { name: /sign in/i }),
  };
};

describe('LoginForm', () => {
  describe('Validation', () => {
    it('shows required errors when submitted empty', async () => {
      const user = userEvent.setup();
      const { submitBtn } = renderLoginForm();

      await user.click(submitBtn);

      expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
      expect(screen.getByText(/password is required/i)).toBeInTheDocument();
    });

    it('shows invalid email error', async () => {
      const user = userEvent.setup();
      const { emailInput, submitBtn } = renderLoginForm();

      await user.type(emailInput, 'notanemail');
      await user.click(submitBtn);

      expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
    });

    it('shows password length error', async () => {
      const user = userEvent.setup();
      const { passwordInput, submitBtn } = renderLoginForm();

      await user.type(passwordInput, 'abc');
      await user.click(submitBtn);

      expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument();
    });
  });

  describe('Successful login', () => {
    it('calls onSuccess with user data after valid submission', async () => {
      const user = userEvent.setup();
      const onSuccess = jest.fn();
      const { emailInput, passwordInput, submitBtn } = renderLoginForm(onSuccess);

      await user.type(emailInput, 'test@example.com');
      await user.type(passwordInput, 'correct');
      await user.click(submitBtn);

      await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(
        expect.objectContaining({ name: 'Alice' })
      ));
    });

    it('shows a loading state while submitting', async () => {
      const user = userEvent.setup();
      // Delay the response to observe loading state
      server.use(
        http.post('/api/auth/login', async () => {
          await new Promise((r) => setTimeout(r, 100));
          return HttpResponse.json({ token: 'tok', user: { name: 'Alice' } });
        })
      );

      const { emailInput, passwordInput, submitBtn } = renderLoginForm();
      await user.type(emailInput, 'test@example.com');
      await user.type(passwordInput, 'correct');
      await user.click(submitBtn);

      expect(screen.getByText(/signing in/i)).toBeInTheDocument();
      expect(submitBtn).toBeDisabled();

      await waitFor(() => expect(screen.queryByText(/signing in/i)).not.toBeInTheDocument());
    });
  });

  describe('API errors', () => {
    it('shows invalid credentials error on 401', async () => {
      const user = userEvent.setup();
      const { emailInput, passwordInput, submitBtn } = renderLoginForm();

      await user.type(emailInput, 'test@example.com');
      await user.type(passwordInput, 'wrongpassword');
      await user.click(submitBtn);

      expect(await screen.findByRole('alert')).toHaveTextContent(/invalid credentials/i);
    });

    it('shows a generic error on network failure', async () => {
      server.use(
        http.post('/api/auth/login', () => HttpResponse.error())
      );
      const user = userEvent.setup();
      const { emailInput, passwordInput, submitBtn } = renderLoginForm();

      await user.type(emailInput, 'test@example.com');
      await user.type(passwordInput, 'correctpassword');
      await user.click(submitBtn);

      expect(await screen.findByRole('alert')).toHaveTextContent(/something went wrong/i);
    });
  });

  describe('Accessibility', () => {
    it('moves focus to the error alert on failed login', async () => {
      const user = userEvent.setup();
      const { emailInput, passwordInput, submitBtn } = renderLoginForm();

      await user.type(emailInput, 'test@example.com');
      await user.type(passwordInput, 'wrongpassword');
      await user.click(submitBtn);

      const alert = await screen.findByRole('alert');
      expect(alert).toHaveFocus();
    });
  });
});

Code Coverage and CI

# Run tests with coverage report
npx jest --coverage --coverageReporters=text lcov

# Run only changed files (fast in CI)
npx jest --onlyChanged

# Watch mode during development
npx jest --watch
// jest.config.ts — coverage thresholds enforce minimums
coverageThreshold: {
  global: {
    branches: 80,
    functions: 80,
    lines: 80,
    statements: 80,
  },
},
# GitHub Actions CI snippet
- name: Run tests
  run: npx jest --ci --coverage --forceExit
- name: Upload coverage
  uses: codecov/codecov-action@v4
  with:
    file: ./coverage/lcov.info
Pro Tip: Add --ci in CI environments. It disables interactive watch mode, fails on new snapshots (rather than prompting to write them), and produces a clean exit code on failure.

FAQ

Should I use getBy*, queryBy*, or findBy*?

Use getBy* when the element should already be in the DOM — it throws immediately if not found, giving you a clear failure message. Use queryBy* only when asserting an element is absent (expect(screen.queryByText('Error')).not.toBeInTheDocument()). Use findBy* when the element appears after an async operation — it polls until the element appears or the timeout expires.

What is the difference between fireEvent and userEvent?

fireEvent dispatches a single synthetic DOM event. userEvent simulates the full browser interaction — for example, user.type(input, 'abc') fires focus, keydown, keypress, input, keyup for each character, just like a real user. For most tests, userEvent is more realistic and catches bugs that fireEvent misses. The tradeoff is that userEvent is asynchronous (requires await).

Why should I use MSW instead of jest.fn() mocking fetch?

Mocking fetch with jest.spyOn(global, 'fetch') ties your tests to the implementation detail of which HTTP client your code uses. If you switch from fetch to axios, all mocks break. MSW intercepts at the network level, so it works regardless of which HTTP library you use and more closely reflects production behavior.

How do I test a component that reads from React Context?

Wrap the component in the context provider inside your test's render call. The cleanest approach is a custom renderWithProviders utility that wraps RTL's render with all your providers (QueryClient, Router, ThemeContext, etc.) and is imported in place of the plain render.

How do I test that a component makes no accessibility violations?

Add jest-axe to your project: npm install --save-dev jest-axe. Then in any test: const { container } = render(<MyComponent />); const results = await axe(container); expect(results).toHaveNoViolations();. This runs the axe accessibility engine on the rendered DOM and reports WCAG violations.