Testing React Components

Testing a React component means rendering it in a simulated environment and making assertions about what the user would actually see and do, rather than poking at internal implementation details. The standard tool for this is React Testing Library (RTL), used together with a test runner like Vitest or Jest. Good component tests catch regressions before users do, document how a component is supposed to behave, and give you the confidence to refactor without fear. This lesson covers the full workflow: rendering components, querying the DOM, simulating clicks and typing, testing asynchronous effects, mocking network calls, and the mistakes that make React tests flaky or useless.

Overview / How Testing React Components Works

React Testing Library is built around one guiding principle: test your components the way a user interacts with them, not the way the code is implemented internally. Instead of reaching into a component’s state or calling its internal functions directly, you render the component into a lightweight in-memory DOM (via jsdom), then query that DOM for text, roles, and labels exactly as a user (or a screen reader) would perceive them.

A typical test does three things, often summarized as Arrange, Act, Assert:

  • Arrange — render the component, usually with render() from @testing-library/react, optionally passing props or wrapping it in providers (context, router, etc.).
  • Act — simulate what a user does: click a button, type into an input, submit a form. This is done with @testing-library/user-event, which dispatches realistic sequences of DOM events (focus, keydown, input, keyup, blur) rather than a single synthetic event.
  • Assert — query the rendered output using screen and check it with matchers from @testing-library/jest-dom, such as toBeInTheDocument() or toHaveTextContent().

Under the hood, render() mounts your component into a detached DOM node using the same reconciliation and commit process React uses in a real browser: React renders your JSX, computes the resulting DOM tree, and commits it into jsdom. When you call user.click() or fire a state update, React schedules a re-render, reconciles the new output against the previous tree, and commits the DOM diff — exactly as it would in production. RTL automatically wraps these updates in React’s act() utility so that all pending state updates and effects flush before your assertions run, which is why you almost never need to call act() yourself when using user-event or the built-in async utilities correctly.

Because RTL queries the rendered output rather than component internals, your tests stay valid even if you refactor a class component to a function component, split a component into two, or change how state is managed — as long as the rendered behavior stays the same. This is the core reason RTL is preferred over older approaches (like Enzyme’s shallow rendering) that coupled tests to implementation details.

Syntax

A typical test file imports rendering and query utilities, plus the component under test, and defines one or more test (or it) blocks:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import MyComponent from "./MyComponent";

test("describes expected behavior", async () => {
  render(<MyComponent />);

  const element = screen.getByText(/hello/i);
  expect(element).toBeInTheDocument();
});
Piece Purpose
render(<Component />) Mounts the component into a virtual DOM for testing.
screen Exposes query methods (getByText, getByRole, etc.) scoped to the whole rendered document.
getBy* Throws immediately if the element isn’t found — use for elements that should already be present.
queryBy* Returns null instead of throwing — use to assert something is absent.
findBy* Returns a Promise and retries until found (or times out) — use for elements that appear asynchronously.
userEvent.setup() Creates a user-event instance that simulates realistic input; its methods (click, type) are async and must be awaited.
expect(...).toBeInTheDocument() A jest-dom matcher confirming the element exists in the DOM.

Examples

Example 1: Rendering and querying a simple component

function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

export default Greeting;
import { render, screen } from "@testing-library/react";
import Greeting from "./Greeting";

test("renders a greeting with the given name", () => {
  render(<Greeting name="Ava" />);

  const heading = screen.getByRole("heading", { name: /hello, ava!/i });
  expect(heading).toBeInTheDocument();
});

What it does: renders Greeting with the prop name="Ava", then looks for a heading element whose accessible name matches /hello, ava!/i. Querying by role (here, heading, which an <h1> exposes automatically) is preferred over querying by CSS class or tag name because it mirrors how assistive technology and real users identify elements. The test passes because React rendered <h1>Hello, Ava!</h1> into the DOM.

Example 2: Simulating a user interaction

import { useState } from "react";

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

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

export default Counter;
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter";

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

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

  const button = screen.getByRole("button", { name: /increment/i });
  await user.click(button);

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

What it does: renders Counter, confirms it starts at Count: 0, finds the button by its accessible role and name, then simulates a real click with user.click(). Because user-event methods are asynchronous, the test function is async and the click is awaited — this ensures React has finished re-rendering before the final assertion runs. The output confirms the button’s onClick handler called setCount(count + 1), triggering a re-render that updated the text to Count: 1.

Example 3: Testing an asynchronous effect with a mocked fetch

import { useEffect, useState } from "react";

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("/api/users")
      .then((res) => res.json())
      .then((data) => {
        setUsers(data);
        setLoading(false);
      });
  }, []);

  if (loading) {
    return <p>Loading users...</p>;
  }

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

export default UserList;
import { render, screen } from "@testing-library/react";
import { vi } from "vitest";
import UserList from "./UserList";

test("shows loading text, then renders fetched users", async () => {
  global.fetch = vi.fn().mockResolvedValue({
    json: () =>
      Promise.resolve([
        { id: 1, name: "Ava" },
        { id: 2, name: "Ben" },
      ]),
  });

  render(<UserList />);

  expect(screen.getByText("Loading users...")).toBeInTheDocument();

  const firstUser = await screen.findByText("Ava");
  expect(firstUser).toBeInTheDocument();
  expect(screen.getByText("Ben")).toBeInTheDocument();
  expect(fetch).toHaveBeenCalledWith("/api/users");
});

What it does: replaces global.fetch with a mock that resolves to a canned list of users, so the test never makes a real network request. Immediately after render(), the component is still in its loading state, so getByText("Loading users...") succeeds synchronously. Because the fetched data arrives asynchronously (after a microtask), the test uses findByText("Ava"), which polls the DOM until the text appears or a timeout is reached. Once resolved, both users are rendered as list items and the assertion on fetch confirms it was called with the expected URL.

How It Works Step by Step / Under the Hood

When a test calls render(<Component />), React Testing Library creates a container <div>, attaches it to jsdom‘s document, and uses react-dom/client‘s createRoot().render() internally to mount your component — the same API your app uses in the browser. Any useEffect hooks with an empty dependency array run once after this initial commit, exactly as they would on a real page load.

When you simulate an interaction with user-event, it dispatches a realistic sequence of native-like DOM events (e.g. pointerdown, mousedown, focus, mouseup, click for a click). Each event that triggers a state update causes React to schedule and flush a re-render inside an act() boundary, so by the time await user.click(...) resolves, the DOM already reflects the new state — no manual waiting required.

For asynchronous work that isn’t tied to a direct user event — a fetch resolving, a timer firing — RTL provides findBy* queries and the standalone waitFor() helper. Both poll the DOM at short intervals until the assertion passes or a timeout elapses, which correctly models the unpredictable timing of real async operations without relying on brittle fixed setTimeout delays.

When a test finishes, RTL’s automatic cleanup (enabled by default in modern versions, or via afterEach(cleanup)) unmounts the component, which triggers any useEffect cleanup functions — clearing timers, aborting subscriptions — so state doesn’t leak between tests.

Common Mistakes

Mistake 1: Not awaiting user-event calls

test("increments on click", () => {
  render(<Counter />);
  const button = screen.getByRole("button", { name: /increment/i });
  userEvent.click(button);
  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});

Since version 14, every user-event method returns a Promise. Calling userEvent.click(button) without await means the assertion can run before React has finished processing the click and re-rendering, producing an intermittent, hard-to-diagnose failure. Fix it by making the test async and awaiting the interaction:

test("increments on click", async () => {
  const user = userEvent.setup();
  render(<Counter />);
  const button = screen.getByRole("button", { name: /increment/i });
  await user.click(button);
  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});

Mistake 2: Querying by test id instead of accessible queries, and using getBy for async content

test("shows fetched user", () => {
  render(<UserList />);
  const item = screen.getByTestId("user-item");
  expect(item).toBeInTheDocument();
});

This fails for two reasons. First, getByText/getByRole reflect what a real user actually perceives, while a data-testid is invisible to users and encourages markup that only exists to satisfy tests. Second, getBy* throws immediately if the element isn’t there yet — since the user list arrives after an async fetch, this test fails before the data ever loads. Prefer an accessible query combined with findBy* for anything that appears asynchronously:

test("shows fetched user", async () => {
  render(<UserList />);
  const item = await screen.findByText("Ava");
  expect(item).toBeInTheDocument();
});

Best Practices

  • Query by role, label, or visible text (getByRole, getByLabelText) before falling back to getByTestId — accessible queries double-check that your UI is usable by assistive technology.
  • Use findBy* or waitFor() for anything that resolves after a fetch, timer, or promise — never add manual setTimeout delays to “wait” for async work.
  • Test behavior and rendered output, not internal state or private functions — this keeps tests valid across refactors.
  • Reset mocks between tests (e.g. vi.restoreAllMocks() or Jest’s clearMocks config) so one test’s mocked fetch doesn’t leak into the next.
  • Keep one behavioral assertion focus per test so failures point directly at what broke.
  • Wrap components that depend on context or routing (e.g. useContext, React Router) in the same providers they’ll have in production when rendering them for a test.

Practice Exercises

  • Write a component LoginForm with an email input, a password input, and a submit button that calls an onSubmit prop with { email, password }. Write a test using userEvent.type() and userEvent.click() that fills in both fields and asserts onSubmit was called with the correct object (use a mock function for onSubmit).
  • Write a Toggle component that renders a button reading “Off” or “On” and flips on click. Write a test asserting the initial text is “Off”, then that it becomes “On” after one click and “Off” again after a second click.
  • Take the UserList example from this lesson and add a test for the error case: mock fetch to reject, add error-state handling to the component (e.g. an <p>Failed to load users</p> message), and assert that message appears using findByText.

Summary

  • React Testing Library renders components into a simulated DOM and lets you query them the way a real user would — by role, label, or visible text.
  • Use render() to mount, screen.getBy*/queryBy*/findBy* to query, and userEvent (always awaited) to simulate interactions.
  • getBy* is synchronous and throws if missing; queryBy* returns null for absence checks; findBy* is async and polls — pick the right one for the timing of what you’re asserting.
  • Mock network calls (like fetch) so tests are fast and deterministic, and reset mocks between tests.
  • Test what the component renders and does, not its internal state — this keeps tests resilient to refactors.