Testing with React Testing Library
Test what a user can see and do, not component internals. The query priority, the difference between getBy, queryBy and findBy, and simulating input with user-event.
After this lesson you can
- Pick the right query for an element, preferring roles and labels over test ids
- Choose between getBy, queryBy, findBy and their All variants from what each does with zero or several matches
- Write an interaction test with user-event and assert on the result the user sees
React Testing Library (RTL) renders a component into a real DOM (usually jsdom under Jest or Vitest) and gives you ways to find elements the way a user would — by their role, their label, their text. It deliberately gives you no access to state, props or component instances. A test that only touches what the user sees keeps passing when you refactor the implementation, and fails when the behaviour breaks.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("adds a todo", async () => {
const user = userEvent.setup();
render(<TodoApp />);
await user.type(screen.getByLabelText("New todo"), "Buy milk");
await user.click(screen.getByRole("button", { name: "Add" }));
expect(screen.getByRole("listitem")).toHaveTextContent("Buy milk");
expect(screen.queryByText("No todos yet")).not.toBeInTheDocument();
});
(toHaveTextContent and toBeInTheDocument come from
@testing-library/jest-dom.)
Which query
Prefer the queries that match how people — including people using
assistive technology — find things: getByRole (with name) first, then
getByLabelText for form fields, then getByPlaceholderText, getByText,
getByDisplayValue, getByAltText, getByTitle, and getByTestId only
when nothing else identifies the element. A test that cannot find a
button by its role and name is often pointing at a real accessibility
problem. By default ByRole queries also skip elements hidden from the
accessibility tree.
get, query, find — and All
Every query comes in variants that differ only in what they do with zero or several matches:
| 0 matches | 1 match | 2+ matches | |
|---|---|---|---|
getBy | throws | element | throws |
queryBy | null | element | throws |
findBy | rejects after timeout | resolves | rejects after timeout |
getAllBy | throws | array | array |
queryAllBy | [] | array | array |
findAllBy |
Use getBy when the element must be there, queryBy only to assert that
something is absent, and findBy when it will appear later (after a
fetch or a timer). The model below runs four small tests against a page
that has two "Download" buttons and loses its "Delete" button when the
user confirms; each test either finishes or reports the error that
failed it:
// An invoice row: a Delete button, and a Download button for each of// its two attachments. Confirming the delete removes the Delete button.function renderInvoice() { let nodes = [ { role: "button", name: "Delete" }, { role: "button", name: "Download" }, { role: "button", name: "Download" }, ]; const matching = (role, name) => nodes.filter((n) => n.role === role && n.name === name); const describe = (role, name, count) => `${count} elements match ${role} "${name}"`; return { confirmDelete() { nodes = nodes.filter((n) => n.name !== "Delete"); }, screen: { getByRole(role, { name }) { const m = matching(role, name); if (m.length !== 1) throw new Error(describe(role, name, m.length)); return m[0]; }, queryByRole(role, { name }) { const m = matching(role, name); if (m.length > 1) throw new Error(describe(role, name, m.length)); return m.length === 1 ? m[0] : null; }, getAllByRole(role, { name }) { const m = matching(role, name); if (m.length === 0) throw new Error(describe(role, name, 0)); return m; }, }, };} function expectEqual(actual, wanted) { if (actual !== wanted) throw new Error(`expected ${wanted}, received ${actual}`);} const tests = { "Delete is gone, checked with getBy"({ screen, confirmDelete }) { confirmDelete(); expectEqual(screen.getByRole("button", { name: "Delete" }), null); }, "Delete is gone, checked with queryBy"({ screen, confirmDelete }) { confirmDelete(); expectEqual(screen.queryByRole("button", { name: "Delete" }), null); }, "one Download button, via queryBy"({ screen }) { expectEqual(screen.queryByRole("button", { name: "Download" }) !== null, true); }, "every Download button, via getAllBy"({ screen }) { expectEqual(screen.getAllByRole("button", { name: "Download" }).length, 2); },}; export function run() { const report = {}; for (const [name, body] of Object.entries(tests)) { try { body(renderInvoice()); // a fresh render for every test report[name] = "PASS"; } catch (err) { report[name] = `FAIL: ${err.message}`; } } return report;}Waiting for things that happen later
findBy is getBy retried until it passes or the timeout (1000 ms by
default) runs out; waitFor(() => …) does the same for any assertion.
A getBy right after an async action fails because the element is not
there yet:
const wait = (ms) => new Promise((r) => setTimeout(r, ms));const dom = []; // The "component" shows a result 30 ms after it renders (a fake fetch).function renderComponent() { setTimeout(() => dom.push({ role: "listitem", name: "Buy milk" }), 30);} function getByRole(role) { const m = dom.filter((n) => n.role === role); if (m.length !== 1) throw new Error(`Unable to find role "${role}"`); return m[0];} async function findByRole(role, { timeout = 1000, interval = 10 } = {}) { const start = Date.now(); for (;;) { try { return getByRole(role); } catch (err) { if (Date.now() - start > timeout) throw err; await wait(interval); } }} export async function run() { renderComponent(); let immediate; try { immediate = getByRole("listitem").name; } catch (err) { immediate = `throws: ${err.message}`; } const found = await findByRole("listitem"); let missing; try { await findByRole("alert", { timeout: 50 }); } catch (err) { missing = `rejects: ${err.message}`; } return { getByRightAway: immediate, findBy: found.name, findByNeverAppears: missing };}If React warns that an update was "not wrapped in act(...)", a state
update happened outside any act scope, typically an async update the
test never waited for. Usually the fix is to await a findBy for the
result, not to add act by hand. RTL's
render and user-event already wrap their work in act.
Interactions and the network
userEvent.setup() then await user.click(), user.type(),
user.keyboard() simulate the full sequence a browser produces (pointer,
focus, key and input events); fireEvent dispatches a single DOM event and
is a lower-level fallback. For components that fetch, mock the network
layer (for example with MSW) rather than the component's own functions, so
the test still exercises the real data flow.
Common mistakes
getByto check that something is not there. It throws before your assertion runs; usequeryByand.not.toBeInTheDocument().getByfor content that appears after a fetch. It fails immediately, as the second example models; usefindBy.
Exercise · Pro
When findBy settles
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade