Testing React Apps

React Testing Library philosophy, querying by role, and complete component test examples.

What React Testing Library optimizes for

React Testing Library (RTL) is the standard tool for testing React components, and it's built around one deliberate philosophy: tests should interact with a component the way a real user does — finding elements by their visible text, label, or accessibility role, and firing real click/type events — rather than reaching into a component's internal state or implementation details. Its own guiding principle, stated directly in its documentation, is "the more your tests resemble the way your software is used, the more confidence they can give you."

In practice that means RTL deliberately makes it awkward to do things like read a component's internal state directly or call an internal method — there's no API for that at all. Instead, everything is queried the way a user would perceive it: getByRole("button", { name: "Submit" }), getByLabelText("Email"), getByText("Welcome back"). This has a real, practical payoff: a test written this way keeps passing after an internal refactor (switching useState for useReducer, renaming an internal variable) as long as the component's observable behavior stays the same — exactly the kind of test that survives a rewrite instead of breaking on it.

RTL is typically paired with a test runner — Vitest (the modern default, especially in a Vite-based project) or Jest — plus @testing-library/jest-dom for extra matchers like toBeInTheDocument() and toHaveTextContent().

Bash
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

A complete example: testing a counter

The component under test:

Javascript
// Counter.jsx
import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

Its test:

Javascript
// Counter.test.jsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter";

describe("Counter", () => {
  it("starts at zero", () => {
    render(<Counter />);
    expect(screen.getByText("Count: 0")).toBeInTheDocument();
  });

  it("increments when the Increment button is clicked", async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByRole("button", { name: "Increment" }));
    await user.click(screen.getByRole("button", { name: "Increment" }));

    expect(screen.getByText("Count: 2")).toBeInTheDocument();
  });

  it("resets back to zero", async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByRole("button", { name: "Increment" }));
    await user.click(screen.getByRole("button", { name: "Reset" }));

    expect(screen.getByText("Count: 0")).toBeInTheDocument();
  });
});

A few things worth calling out line by line: render(<Counter />) mounts the component into a virtual DOM (backed by jsdom in a Node test environment). screen.getByRole("button", { name: "Increment" }) finds the button the same way a screen reader or a sighted user would — by its role and its visible/accessible text — rather than a CSS selector or a test-only data-testid attribute. userEvent.setup() plus user.click(...) fires a more realistic sequence of events than the older, lower-level fireEvent.click(...) (which only dispatches a single synthetic event); prefer userEvent for anything simulating real interaction. Every interaction from userEvent is asynchronous, which is why each call is awaited.

Testing a form with async behavior

Testing something that fetches or validates asynchronously combines the same queries with RTL's findBy* queries, which wait for an element to appear rather than expecting it to exist immediately:

Javascript
// LoginForm.jsx
import { useState } from "react";

export default function LoginForm({ onLogin }) {
  const [email, setEmail] = useState("");
  const [error, setError] = useState(null);

  async function handleSubmit(e) {
    e.preventDefault();
    if (!email.includes("@")) {
      setError("Enter a valid email address");
      return;
    }
    setError(null);
    await onLogin(email);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" value={email} onChange={e => setEmail(e.target.value)} />
      <button type="submit">Log in</button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}
Javascript
// LoginForm.test.jsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LoginForm from "./LoginForm";

describe("LoginForm", () => {
  it("shows a validation error for an invalid email", async () => {
    const user = userEvent.setup();
    const onLogin = vi.fn();
    render(<LoginForm onLogin={onLogin} />);

    await user.type(screen.getByLabelText("Email"), "not-an-email");
    await user.click(screen.getByRole("button", { name: "Log in" }));

    expect(await screen.findByRole("alert")).toHaveTextContent("Enter a valid email address");
    expect(onLogin).not.toHaveBeenCalled();
  });

  it("calls onLogin with the entered email when valid", async () => {
    const user = userEvent.setup();
    const onLogin = vi.fn().mockResolvedValue(undefined);
    render(<LoginForm onLogin={onLogin} />);

    await user.type(screen.getByLabelText("Email"), "ada@example.com");
    await user.click(screen.getByRole("button", { name: "Log in" }));

    expect(onLogin).toHaveBeenCalledWith("ada@example.com");
  });
});

vi.fn() creates a mock function, standing in for a real API call so the test doesn't depend on a network request actually succeeding. screen.getByLabelText("Email") only works because the real component's <label htmlFor="email"> is correctly associated with the <input id="email"> — which is itself a small, genuine accessibility win RTL nudges you toward: if a query like this is awkward to write, that's often a sign the markup itself has an accessibility gap worth fixing, not just a testing inconvenience.

Common mistakes

  • Reaching for getByTestId("submit-button") as the default query instead of getByRole/getByLabelText — a data-testid works, but it tests an implementation detail invisible to a real user, and it's the RTL team's own explicitly documented last resort, not a first choice.
  • Using fireEvent.click(...) where userEvent.click(...) would more accurately simulate what actually happens in the browser (focus, hover, and a full event sequence) — userEvent is the more realistic default for anything a real user would do.
  • Using getBy* for something that appears asynchronously (after a fetch resolves) instead of findBy*getBy* throws immediately if the element isn't already in the DOM, while findBy* returns a promise that retries until the element appears or a timeout is hit.
  • Testing a component's internal state or calling its internal functions directly instead of asserting on what actually renders — RTL intentionally has no API for that, since a test coupled to implementation details breaks on refactors that don't change any real user-facing behavior.