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
countvariable, 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.
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.
| Priority | Query | When to Use |
|---|---|---|
| 1 (highest) | getByRole | Almost everything: buttons, headings, inputs, links, checkboxes. Most robust. |
| 2 | getByLabelText | Form fields associated with a label element. |
| 3 | getByPlaceholderText | Inputs where only a placeholder is available (prefer label). |
| 4 | getByText | Non-interactive elements: paragraphs, spans, divs. |
| 5 | getByDisplayValue | Pre-filled form inputs. |
| 6 | getByAltText | Images and elements with an alt attribute. |
| 7 | getByTitle | Elements with a title attribute. |
| 8 (last resort) | getByTestId | Only 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
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...'));
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
--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.