React Testing Library
React Testing Library (RTL) renders your components into a real DOM and then lets you interact with them the way a person would—by finding a button by its label, typing into a field, and reading the text that appears. It deliberately gives you no access to component internals like state or props, which pushes you toward tests that assert on observable behavior. The payoff is tests that keep passing through refactors and catch the bugs your users would actually hit. This page covers render, the query families, jest-dom assertions, and why avoiding implementation-detail tests matters.
The guiding philosophy
RTL’s motto is simple: the more your tests resemble the way your software is used, the more confidence they give you. A user does not know that a component holds a count in useState; they know they clicked “Increment” and the number on screen changed. So RTL gives you queries that mirror how people (and assistive technology) perceive a page—by ARIA role, by label, by visible text—and discourages reaching for CSS selectors or test IDs.
Rendering a component
render mounts a component into a container attached to document.body and returns utilities. In practice you rarely use the return value directly; instead you query through the global screen object, which always searches the whole document.
// Greeting.jsx
export function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
// Greeting.test.jsx
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
import { Greeting } from "./Greeting";
test("renders the personalized greeting", () => {
render(<Greeting name="Ada" />);
expect(screen.getByRole("heading", { name: "Hello, Ada!" })).toBeInTheDocument();
});
After each test, RTL automatically unmounts and cleans up the DOM (when jest-dom’s auto-cleanup is enabled, which it is by default with Vitest/Jest), so tests stay isolated.
Queries: getBy, queryBy, findBy
Every query comes in three variants, and choosing the right one communicates your intent.
| Variant | Returns | No match | Multiple matches | Use when |
|---|---|---|---|---|
getBy* | Element | Throws | Throws | Element should already be present |
queryBy* | Element or null | Returns null | Throws | Asserting something is absent |
findBy* | Promise of element | Rejects | Rejects | Element appears asynchronously |
There are also getAllBy*, queryAllBy*, and findAllBy* for matching multiple elements at once.
Which query to reach for
RTL ranks queries by how closely they reflect the user experience. Prefer them roughly in this order:
getByRole— the primary tool. Finds elements by their accessible role (button,heading,textbox,checkbox,link), usually narrowed with thenameoption that matches the accessible name.getByLabelText— for form fields associated with a<label>. This is how a user finds an input.getByPlaceholderText— a fallback when there is no proper label.getByText— for non-interactive content like paragraphs and spans.getByDisplayValue— to find a filled-in form control by its current value.getByTestId— last resort, for elements with no accessible handle (data-testid).
Tip: If
getByRolecan’t find your element, that is often a real accessibility bug—adivacting as a button, or an input with no label. Fix the markup rather than dropping down togetByTestId.
Assertions with jest-dom
The @testing-library/jest-dom package adds DOM-aware matchers that read naturally and give clear failure messages. Common ones include toBeInTheDocument, toBeVisible, toBeDisabled, toHaveTextContent, toHaveValue, toHaveAttribute, and toBeChecked. Register them once in your setup file:
// src/test/setup.js
import "@testing-library/jest-dom/vitest";
A worked example
Here is a small form whose submit button is disabled until the email field is non-empty. The test fills the field, submits, and waits for an async confirmation message.
// SubscribeForm.jsx
import { useState } from "react";
export function SubscribeForm({ onSubscribe }) {
const [email, setEmail] = useState("");
const [status, setStatus] = useState("idle");
async function handleSubmit(event) {
event.preventDefault();
setStatus("loading");
await onSubscribe(email);
setStatus("done");
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button type="submit" disabled={email.length === 0}>
Subscribe
</button>
{status === "done" && <p role="status">You're subscribed!</p>}
</form>
);
}
// SubscribeForm.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { SubscribeForm } from "./SubscribeForm";
test("subscribes the user and shows a confirmation", async () => {
const user = userEvent.setup();
const onSubscribe = vi.fn().mockResolvedValue(undefined);
render(<SubscribeForm onSubscribe={onSubscribe} />);
const button = screen.getByRole("button", { name: /subscribe/i });
expect(button).toBeDisabled();
await user.type(screen.getByLabelText(/email address/i), "[email protected]");
expect(button).toBeEnabled();
await user.click(button);
expect(onSubscribe).toHaveBeenCalledWith("[email protected]");
expect(await screen.findByRole("status")).toHaveTextContent("You're subscribed!");
});
Running the suite confirms the behavior end to end.
Output:
✓ src/SubscribeForm.test.jsx (1 test) 41ms
✓ subscribes the user and shows a confirmation
Test Files 1 passed (1)
Tests 1 passed (1)
Duration 486ms
The test never inspects email or status; it only checks what the user sees and does. The findByRole call uses RTL’s built-in waiting, so there are no arbitrary setTimeout calls.
Avoiding implementation-detail tests
A test couples to an implementation detail when it breaks during a refactor that does not change behavior. Asserting on a component’s state, calling instance methods, or matching generated CSS class names are all red flags. If you swapped useState for useReducer, or restyled the form, the example above would still pass—because it speaks only in terms of roles, labels, and visible text.
Warning: Avoid querying by
container.querySelector(".btn-primary"). Class names are styling concerns; a designer changing them should never break your test suite.
Best practices
- Reach for
getByRolefirst and add thenameoption; it mirrors how users and screen readers find elements. - Use
queryBy*only to assert absence, andfindBy*for anything that appears asynchronously. - Let jest-dom matchers (
toBeInTheDocument,toBeDisabled) do the talking—they produce readable failures. - Never assert on internal state, instance methods, or CSS class names.
- Treat a failing
getByRoleas a possible accessibility bug to fix in the markup, not a reason to usedata-testid. - Keep tests independent; rely on automatic cleanup and reset mocks between tests.