Skip to content
React rc testing 5 min read

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.

VariantReturnsNo matchMultiple matchesUse when
getBy*ElementThrowsThrowsElement should already be present
queryBy*Element or nullReturns nullThrowsAsserting something is absent
findBy*Promise of elementRejectsRejectsElement 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 the name option 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 getByRole can’t find your element, that is often a real accessibility bug—a div acting as a button, or an input with no label. Fix the markup rather than dropping down to getByTestId.

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 getByRole first and add the name option; it mirrors how users and screen readers find elements.
  • Use queryBy* only to assert absence, and findBy* 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 getByRole as a possible accessibility bug to fix in the markup, not a reason to use data-testid.
  • Keep tests independent; rely on automatic cleanup and reset mocks between tests.
Last updated June 14, 2026
Was this helpful?