@testing-library/react
React Testing Library renders your React components into a DOM (usually jsdom) and gives you queries that find elements the way a user would: by role, label, or visible text. The whole point is testing behavior instead of implementation details, so refactors that do not change what the user sees do not break tests. It is the de facto standard for React component tests and what the React docs themselves point to.
If you test React components in a Node test runner, this is the correct default and the alternatives are worse. Just accept the philosophy: if you find yourself reaching for internals, you either restructure the test or move that case to a real browser.
Use it if
- You write unit or integration tests for React components in Jest or Vitest and want tests that survive refactors
- You want accessibility pressure built into testing: getByRole fails when your markup has no proper roles or labels, which catches real a11y problems
- You test user flows inside a component tree (fill form, click submit, assert the result) without paying for a real browser
- You are on React 18 or 19; v16 supports both and tracks new React versions quickly
- You need to test real browser behavior: jsdom has no layout engine, so anything involving CSS, element sizes, scrolling, drag-and-drop, or media will silently not work; use Playwright or Cypress component tests instead
- You want to assert on state, instance methods, or shallow-render one level deep, enzyme-style; RTL deliberately refuses to expose any of that and fighting the philosophy is miserable
- Your components are mostly thin wrappers over heavy visual libraries (canvas, WebGL, charts); there is nothing meaningful for DOM queries to find
- You have not budgeted time for the async model: act() warnings, findBy vs getBy, and waitFor misuse are the top source of flaky React tests for teams new to it
Setup reality
Since v16 you install @testing-library/react plus @testing-library/dom yourself as a peer dependency, and @types/react / @types/react-dom on TypeScript projects; forgetting the dom package is the classic post-upgrade error. You still need a test runner with a jsdom environment (jest-environment-jsdom or Vitest with environment: 'jsdom'), @testing-library/jest-dom for readable matchers, and usually a custom render wrapper for your providers. None of it is hard, all of it is fiddly, and every project rebuilds the same setup file.
Patterns
Render a component and assert on outputrender-and-query
import { render, screen } from '@testing-library/react';
test('shows greeting', () => {
render(<Greeting name="Ada" />);
expect(screen.getByRole('heading', { name: /hello, ada/i })).toBeInTheDocument();
});Prefer screen over destructuring render's return; toBeInTheDocument needs @testing-library/jest-dom registered in setup.
Click and type like a real useruser-interaction
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('submits the form', async () => {
const user = userEvent.setup();
render(<Login onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'a@b.co');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(onSubmit).toHaveBeenCalled();
});Use userEvent.setup() and await every call; fireEvent skips real event sequences (focus, keydown, pointer) and hides bugs.
Wait for content that appears after a fetchasync-appearance
render(<Profile userId="1" />);
const name = await screen.findByText(/ada lovelace/i);
expect(name).toBeVisible();findBy = getBy + waitFor with a 1000ms default timeout; using getBy for async content is the number one cause of 'works locally, fails in CI'.
Assert an element is NOT thereassert-absence
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// wait for a spinner to go away
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));getBy throws when nothing matches, so absence checks must use queryBy.
Wrap renders with app providerscustom-render-providers
// test-utils.tsx
const AllProviders = ({ children }) => (
<QueryClientProvider client={testClient}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>
);
export const renderWithProviders = (ui, options) =>
render(ui, { wrapper: AllProviders, ...options });Create a fresh QueryClient (or store) per test, or state leaks between tests in ways that only fail when run together.
Pick the right queryquery-priority
// in order of preference:
screen.getByRole('button', { name: /save/i }); // best
screen.getByLabelText(/email/i); // form fields
screen.getByPlaceholderText(/search/i);
screen.getByText(/no results/i); // non-interactive
screen.getByTestId('row-42'); // last resortIf getByRole cannot find your element, the markup is usually the bug: a div with onClick has no role.
Test a prop change on the same instancererender-props
const { rerender } = render(<Counter value={1} />);
expect(screen.getByText('1')).toBeInTheDocument();
rerender(<Counter value={2} />);
expect(screen.getByText('2')).toBeInTheDocument();rerender keeps component state; a second render() call would mount a fresh tree instead.
Test a custom hook directlytest-hooks
import { renderHook, act } from '@testing-library/react';
test('useCounter increments', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});renderHook moved into @testing-library/react in v13; the separate react-hooks package is dead, do not install it.
Wait for an assertion to passwaitfor-assertion
await waitFor(() => {
expect(mockApi.save).toHaveBeenCalledTimes(1);
});Keep exactly one assertion inside waitFor and never put side effects (clicks, renders) in the callback; it retries the whole block.
See what actually rendereddebug-output
render(<App />);
screen.debug(); // prints the DOM
screen.debug(undefined, 30000); // raise the truncation limit
logRoles(container); // prints every ARIA role presentWhen getByRole fails, logRoles shows you the roles that exist; the error message also suggests close matches.
Query inside one section of the pagescoped-queries
import { within } from '@testing-library/react';
const row = screen.getByRole('row', { name: /ada/i });
await user.click(within(row).getByRole('button', { name: /delete/i }));within() is the clean fix when getByRole matches multiple elements across the page.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @playwright/test | npm | You want component or end-to-end tests in a real browser instead of jsdom |
| cypress | npm | Your team prefers an interactive browser test runner with time-travel debugging |
| enzyme | npm | Only for maintaining legacy test suites; it is unmaintained and has no official adapter beyond React 16 |