Testing React Components with React Testing Library
Learn to write component tests that verify behavior from the user's perspective using React Testing Library: rendering, querying, simulating interactions, testing forms, async UI, loading/error/empty states, mocking APIs, and using accessible queries — while avoiding brittle implementation-detail tests.
A component test should answer one question: if a real person used this screen, would it work? React Testing Library (RTL) is built around that philosophy — it deliberately makes it hard to reach into component internals (state, props, instance methods) and instead pushes you toward querying the rendered DOM the way a user or assistive technology would, then firing real events and asserting on what appears. This lesson walks through the full toolkit: render, screen, the query families, userEvent, async utilities like waitFor and findBy, mocking network calls, and the four states every non-trivial UI has — loading, success, error, and empty — plus the discipline of writing tests that survive refactors because they never mention implementation details like internal state variable names or CSS class hooks.
Rendering and the query priority ladder
Every RTL test starts with render(), which mounts a component into a detached DOM node attached to document.body and returns utilities for querying it. Rather than destructuring those utilities, the modern convention is to import screen and query directly off it — screen.getByRole, screen.getByText — because screen always points at the current document.body, so you don't have to thread render's return value through every helper function.
RTL exposes three query variants per query type: getBy* throws immediately if zero or more than one match is found (use it for elements you assert must exist now), queryBy* returns null instead of throwing (use it exclusively for asserting something is absent), and findBy* returns a Promise that retries until the element appears or a timeout elapses (use it for anything that shows up after an async operation, like data loaded from an API).
The library's official priority order for choosing a query exists to bias your tests toward accessibility: prefer getByRole (matches the ARIA role tree exactly what a screen reader announces), then getByLabelText for form fields, then getByPlaceholderText, getByText, getByDisplayValue, and only as a last resort getByTestId — a hook (data-testid) that exists purely for tests and carries no meaning to real users.
• getBy* — throws if not found or if multiple matches; use for 'this must be on screen right now' • queryBy* — returns null if not found; the only correct way to assert non-existence • findBy* — async, retries with polling; use for anything that appears after a state update or fetch • getAllBy* / queryAllBy* / findAllBy* — the plural forms return arrays instead of throwing on multiple matches
Accessible queries over implementation details
getByRole is the workhorse query. Every interactive element has an implicit ARIA role — a <button> is role 'button', an <input type="checkbox"> is role 'checkbox', a heading is role 'heading' with a level. getByRole('button', { name: /submit/i }) finds a button by its accessible name (its visible text, aria-label, or aria-labelledby), which is exactly how a screen reader user would locate it. This single query style catches accessibility regressions for free: if a button loses its label or a form field loses its association to its <label>, the test that used to find it now fails.
Avoid querying by CSS class name, DOM structure (container.querySelector('.btn-primary > span')), or component internals. Those tests know things a real user could never know, so they break the moment you rename a class or restructure markup even though the feature still works — that's a false failure, and false failures teach developers to distrust the test suite.
Testing Library actively discourages 'shallow rendering' and enzyme-style APIs like .instance() or .state() that were common in older React test setups; RTL has no equivalent on purpose, because those APIs test the component's implementation, not its contract with the user.
• Prefer: screen.getByRole('textbox', { name: 'Email' }) • Avoid: container.querySelector('input#email-field-internal') • Prefer: screen.getByText('Order confirmed') • Avoid: expect(wrapper.state('status')).toBe('confirmed')
Simulating user interaction with userEvent
fireEvent (from RTL core) dispatches a single raw DOM event, which is lower-level than how humans actually interact with pages — typing a character involves keydown, keypress, input, and keyup events plus focus changes, and a real click involves pointer events and focus. @testing-library/user-event wraps fireEvent to simulate the full realistic sequence, so it catches bugs that a bare fireEvent.change would miss, such as a component that only updates on 'blur' or an onKeyDown handler that fireEvent.change never triggers.
user-event's API is async as of v14: every interaction method returns a Promise that must be awaited, and setup() should be called once per test to create a user session object bound to a fresh instance, which correctly models things like held-down modifier keys across a sequence of calls.
• user.click(el) — realistic pointer down/up plus focus • user.type(el, 'text') — types character by character, firing keydown/keypress/input/keyup for each • user.tab() — moves focus the way Tab does, respecting tabIndex and disabled elements • user.selectOptions(select, 'value') — for native <select> elements • user.clear(el) — selects all and deletes, mirroring how a user would clear a field • user.hover(el) / user.unhover(el) — for tooltips and hover-triggered UI
act() and why 'not wrapped in act' warnings appear
React's act() utility ensures that all state updates, effects, and their resulting DOM mutations triggered by some code are flushed and applied before you make assertions — it exists so your test observes the DOM in the same fully-settled state a user would see, rather than mid-update. render() and fireEvent from RTL already wrap their calls in act() internally, and user-event does too, which is why you rarely call act() yourself in modern RTL tests.
The warning 'An update to Component inside a test was not wrapped in act(...)' means a state update happened outside of any act() boundary that the test could see — commonly because a promise resolved and called setState after the test function had already returned, or a timer fired later. The fix is almost never to sprinkle act() everywhere; it's to await the actual async operation your code performs, typically by using findBy* (which internally polls inside act) or by awaiting user-event calls and any promises your test itself kicks off.
If you must wait for a side effect that isn't reflected by a specific element appearing (rare), waitFor(callback) polls callback repeatedly inside act() until it stops throwing or times out — it's the general-purpose escape hatch, but findBy* queries should cover the vast majority of real cases.
Testing forms
Form tests combine getByRole/getByLabelText to grab fields, user.type/user.click to fill them in, and then assertions on either the resulting DOM (an error message appearing) or on a mocked submit handler having been called with the right payload. Always query the submit button by its accessible role and name rather than by test id, since a submit button's text is itself part of the user experience worth protecting.
For validation, assert on the error text becoming visible via getByRole('alert') or getByText, not on some internal 'errors' object — and assert the field gets aria-invalid="true" and is associated with the error via aria-describedby if your component sets that up, since that's what makes the error accessible to screen reader users.
Loading, error, and empty states with mocked APIs
Any component that fetches data has at least four states to test: loading (request in flight), success (data rendered), error (request failed), and empty (request succeeded but returned zero items) — each deserves its own test rather than being an afterthought of the happy-path test.
Mock the network layer, not your own component. With fetch, jest.spyOn(global, 'fetch') or a library like msw (Mock Service Worker) intercepts at the network boundary so your component's actual fetch call, headers, and URL construction are exercised for real; with axios-based code, jest.mock('axios') achieves the same end. Never mock your own custom hook or data-fetching function directly if you can mock one layer lower — the closer the mock is to the real network boundary, the more of your actual code gets covered.
To test the loading state, render the component with a fetch mock whose promise never resolves during that test (or resolves on a later tick) and assert the loading indicator (commonly role='status') is present immediately after render, before awaiting anything. To test error, resolve the mock with a rejected promise or a non-ok response and use findByRole('alert') or findByText to wait for the error UI. To test empty, resolve with an empty array and assert an empty-state message appears via findByText, and that no list items exist via queryAllByRole('listitem') having length zero.
Avoiding implementation-detail tests
The single guiding rule from Kent C. Dodds, RTL's philosophical author: 'The more your tests resemble the way your software is used, the more confidence they can give you.' Concretely that means: don't assert on internal state, don't call component methods directly, don't rely on the exact number of times a child component rendered, and don't snapshot large chunks of markup (snapshot tests pass even when the visible behavior breaks, and fail on harmless refactors, training developers to blindly update them).
A useful smoke test: if you rewrote the component using entirely different internal state management (say, swapping useState for a reducer or for a different state library) but kept the same rendered output and behavior, would your test still pass unmodified? If yes, it's testing behavior. If it would need to change, it was coupled to implementation.
Core RTL + user-event test shape
userEvent.setup() must be called before render() ideally, or at least before the first interaction, once per test. Every user-event method call must be awaited — forgetting await is the most common source of flaky RTL tests. screen never needs to be re-queried after re-renders; it always reflects the current document.body.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('description of user-observable behavior', async () => {
const user = userEvent.setup();
render(<ComponentUnderTest />);
// 1. find something the way a user would
const button = screen.getByRole('button', { name: /submit/i });
// 2. interact with it realistically
await user.click(button);
// 3. assert on the resulting, user-visible outcome
expect(await screen.findByText(/success/i)).toBeInTheDocument();
});Code Examples
render() mounts the component into the test DOM. getByRole finds the <h1> by its implicit 'heading' role and its accessible name (its text content), which mirrors how a screen reader would announce it — no need to inspect props or state directly. Expected result: Test passes: 1 element found matching role 'heading' with accessible name 'Hello, Ada!' screen.getByRole('heading', { name: 'Hello, Ada!' })
The test never touches the useState internals. It finds the button by its current accessible name, clicks it twice with a realistic pointer sequence, and re-queries for the button's new accessible name — proving the UI updated the way a user would perceive it.
await user.click(button);
Common mistake: Using fireEvent.click without await, or reusing a stale reference to button and expecting its text to have changed in place — DOM elements from getByRole are live nodes, but a fresh getByRole re-query after state changes is the clearer, more robust pattern.
getByLabelText requires the input to have a properly associated <label>, which the test enforces implicitly by depending on it — an accessibility win as a side effect of testability. jest.fn() replaces the real submit handler so the test can assert on the exact payload without depending on any backend.
expect(await screen.findByRole('alert')).toHaveTextContent(/enter a valid email/i);
Each of the four data-fetching states gets its own focused test with its own fetch mock resolution. The loading test intentionally returns a promise that never resolves within the test so the loading UI can be asserted synchronously; the other three use findBy* to wait for the async state to settle after the mocked promise resolves. Mocking at the fetch boundary (rather than mocking the component's internal useUsers hook) means the real fetch-call construction, response handling, and error-branch logic in the component all execute during the test, maximizing real coverage.
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => [] });
The 'bad' test depends on a CSS class name and an invented data attribute nobody but the test reads — rename the class during a refactor and the test breaks despite no behavior change. The 'good' test uses the switch's ARIA role and aria-checked, which is both what a screen reader relies on and stable across internal refactors.
Common mistake: Querying via container.querySelector and custom data-internal-* attributes instead of role/name/aria-state — this is the single most common way RTL tests become brittle.
import { render, screen } from '@testing-library/react';
function Greeting({ name }: { name: string }) {
return <h1>Hello, {name}!</h1>;
}
test('renders a personalized greeting', () => {
render(<Greeting name="Ada" />);
const heading = screen.getByRole('heading', { name: 'Hello, Ada!' });
expect(heading).toBeInTheDocument();
});import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
}
test('increments count on click', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: /count: 0/i });
await user.click(button);
await user.click(button);
expect(screen.getByRole('button', { name: /count: 2/i })).toBeInTheDocument();
});import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SignupForm } from './SignupForm';
test('shows a validation error for an invalid email', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<SignupForm onSubmit={handleSubmit} />);
const emailInput = screen.getByLabelText(/email/i);
const submitButton = screen.getByRole('button', { name: /sign up/i });
await user.type(emailInput, 'not-an-email');
await user.click(submitButton);
expect(await screen.findByRole('alert')).toHaveTextContent(/enter a valid email/i);
expect(handleSubmit).not.toHaveBeenCalled();
});
test('submits the form with valid data', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<SignupForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'sup3rSecret!');
await user.click(screen.getByRole('button', { name: /sign up/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'sup3rSecret!',
});
});import { render, screen } from '@testing-library/react';
import { UserList } from './UserList';
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
test('shows a loading indicator while the request is in flight', () => {
(global.fetch as jest.Mock).mockReturnValue(new Promise(() => {})); // never resolves
render(<UserList />);
expect(screen.getByRole('status')).toHaveTextContent(/loading/i);
});
test('renders users on successful fetch', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => [{ id: 1, name: 'Grace Hopper' }],
});
render(<UserList />);
expect(await screen.findByText('Grace Hopper')).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
test('shows an error message when the request fails', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 500 });
render(<UserList />);
expect(await screen.findByRole('alert')).toHaveTextContent(/couldn't load users/i);
});
test('shows an empty state when there are no users', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: asy
…// BAD: coupled to implementation details
test('bad: checks internal state directly', () => {
const { container } = render(<Toggle />);
const wrapper = container.querySelector('.toggle-wrapper');
// Reaching into class names and pretending we can read internal state
expect(wrapper?.getAttribute('data-internal-state')).toBe('off');
});
// GOOD: asserts on what a user perceives
test('good: reflects toggle state through accessible attributes', async () => {
const user = userEvent.setup();
render(<Toggle label="Notifications" />);
const toggle = screen.getByRole('switch', { name: /notifications/i });
expect(toggle).toHaveAttribute('aria-checked', 'false');
await user.click(toggle);
expect(toggle).toHaveAttribute('aria-checked', 'true');
});RTL query decision flow
Work down the ladder — reach for getByTestId only when no accessible query can locate the element.
Need to find an element?
|
v
Can a user perceive it by role/name?
(button, heading, textbox...)
|
yes ─────┴───── no
| \
v v
getByRole(...) Has an associated <label>?
|
yes ─────┴───── no
| \
v v
getByLabelText(...) getByText(...) /
getByPlaceholderText(...)
|
still nothing semantic?
|
v
getByTestId(...) (last resort)Important Rules
Query the way a user or assistive technology would: role and accessible name first, test IDs last.
Use getBy* for 'must exist now', queryBy* only for 'must not exist', findBy* for anything that appears asynchronously.
Always await every user-event interaction and every findBy*/waitFor call.
Mock at the network boundary (fetch/axios), not at your own hooks or components, to keep real logic under test.
Write a separate test for each of loading, success, error, and empty states on any data-driven component.
Never assert on internal state, props, or instance methods — assert on rendered output and accessible attributes.
Treat 'not wrapped in act(...)' warnings as a signal that an async operation in your test isn't being awaited, and fix the await, not by wrapping code in act() manually.
Why this matters in production
Teams that migrated from Enzyme's shallow-render, state-inspecting tests to RTL consistently report far fewer 'false failure' PR blockers — tests that fail on a harmless internal refactor (renaming a hook, switching from class to function components, changing a CSS class) even though the feature still works exactly as before. Because RTL tests only know what a user knows, they also double as a lightweight accessibility check: a getByRole('button', { name: ... }) query literally cannot pass on a clickable <div> with no role or accessible name, catching accessibility bugs that a visual-only QA pass would miss entirely.
fireEvent.change(input, { target: { value: 'abc' } }); expect(...) await user.type(input, 'abc'); expect(...) fireEvent.change fires a single low-level event and skips the realistic keydown/input sequence, so it can pass even when a component's onKeyDown-driven behavior is broken. user.type simulates the real keystroke sequence and must be awaited.
const { container } = render(<App />); container.querySelector('#submit-btn') screen.getByRole('button', { name: /submit/i })
Querying by ID/class couples the test to markup structure that has nothing to do with what a user sees, so refactors that don't change behavior will still break the test.
RTL has no API to read component state on purpose — testing internal state instead of rendered output produces tests that pass even when the loading indicator itself is broken or missing.
test('shows users', async () => { render(<UserList />); expect(screen.getByText('Grace Hopper')).toBeInTheDocument(); }) expect(await screen.findByText('Grace Hopper')).toBeInTheDocument();
getByText throws synchronously and runs before the mocked fetch promise has resolved, so this test fails intermittently or always, depending on timing. findByText polls until the element appears.
[mcq] Which query should you use to assert that an element is NOT present on the screen? A. getByText B. queryByText C. findByText D. getAllByText
B. queryByText — getBy* and findBy* both throw when no match is found, which would make asserting absence impossible without a try/catch. queryBy* returns null instead, making expect(screen.queryByText(...)).not.toBeInTheDocument() the correct pattern.
[mcq] A test intermittently fails with 'An update ... was not wrapped in act(...)'. What is the most likely correct fix? A. Wrap the entire test body in act(() => { ... }) B. Await the async operation (e.g. use findBy* or await the promise) instead of asserting immediately C. Increase the Jest test timeout D. Replace getByRole with querySelector to avoid triggering React updates
B. Await the async operation (e.g. use findBy* or await the promise) instead of asserting immediately — The warning means a state update happened after the test's synchronous code finished but wasn't awaited. render/fireEvent/user-event already use act() internally; the fix is to properly await the async work (typically via findBy* or waitFor), not to manually wrap code in act().
[mcq] Why does RTL recommend getByRole with an accessible name over getByTestId for a submit button? A. getByTestId is deprecated and will be removed B. getByRole is faster to execute C. getByRole verifies the element is reachable the way a real user (including assistive-technology users) finds it, catching accessibility regressions as a side effect D. getByTestId does not work with userEvent
C. getByRole verifies the element is reachable the way a real user (including assistive-technology users) finds it, catching accessibility regressions as a side effect — getByTestId matches an attribute meaningful only to the test, so it can pass even if the button has no accessible name for a screen reader. getByRole with a name query fails in that scenario, surfacing a real accessibility bug.
[mcq] When testing a component that fetches data, which practice gives the most confidence while keeping the test fast and deterministic? A. Let the test hit the real production API B. Mock the component's internal custom hook (e.g. useUsers) so no fetch logic runs at all C. Mock fetch/axios at the network boundary so the component's real request/response handling logic executes against a controlled response D. Skip testing loading/error/empty states since they rarely occur in production
C. Mock fetch/axios at the network boundary so the component's real request/response handling logic executes against a controlled response — Mocking at the network boundary keeps the component's real data-fetching, error-handling, and rendering logic under test while remaining fast and fully deterministic — unlike hitting a real API (slow, flaky, external dependency) or mocking your own hook (which bypasses the code you actually want covered).
Write RTL tests for a SearchBox component that: (1) renders a text input labeled 'Search' and a 'Search' button, (2) calls an onSearch(query) prop with the current input value when the button is clicked, (3) shows a role="status" element reading 'Searching…' while a mocked async onSearch prop is pending, and (4) shows a role="alert" with 'No results found' when onSearch resolves to an empty array. Use userEvent for all interactions and findBy* for anything asynchronous.
test('shows loading then empty state', async () => { const user = userEvent.setup(); let resolveSearch: (v: string[]) => void; const onSearch = jest.fn(() => new Promise<string[]>((res) => { resolveSearch = res; })); render(<SearchBox onSearch={onSearch} />); await user.type(screen.getByLabelText(/search/i), 'react'); await user.click(screen.getByRole('button', { name: /search/i })); expect(screen.getByRole('status')).toHaveTextContent(/searching/i); resolveSearch!([]); expect(await screen.findByRole('alert')).toHaveTextContent(/no results found/i); expect(onSearch).toHaveBeenCalledWith('react'); });
Mock onSearch as a jest.fn() that returns a controllable Promise (store its resolve function in a variable) so you can assert the loading state before resolving it, then resolve it and assert the empty-state message with findByRole.