React Testing Library Tutorial: Test Behavior, Not Implementation

Write React tests that click buttons and read visible text instead of inspecting state and props, so future refactors do not break them.

6 min read

React Testing Library is a library for testing React components the way a user experiences them. Its guiding principle is that the more your tests resemble the way your software is used, the more confidence they can give you. You assert on visible output and real interactions instead of component internals such as state variables or prop objects.

What the library is and is not

React Testing Library builds on DOM Testing Library and adds helpers for rendering React components and working with the DOM they produce. It is not a test runner, and it does not replace Vitest or Jest. Install it as a development dependency together with its peer dependency.

bashbash
npm install --save-dev @testing-library/react @testing-library/dom

The library is a replacement for Enzyme with the same goal in a harder-to-misuse form. Because it only exposes utilities that work on DOM nodes, it steers you away from inspecting component instances. That guiding principle applies to every query and assertion in this section.

Write the first behavior test

Start with a component that shows a greeting based on a prop.

App.jsxApp.jsx
export default function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

The test renders the component and reads the visible text back. Matchers such as toBeInTheDocument come from the jest-dom companion package, which is configured once in the test setup.

App.jsxApp.jsx
import { render, screen } from "@testing-library/react";
import Greeting from "./Greeting";
 
test("shows the name in the heading", () => {
  render(<Greeting name="Ada" />);
 
  expect(screen.getByText("Hello, Ada!")).toBeInTheDocument();
});

The render call mounts the component into a container appended to the document. The screen object exposes queries bound to the whole document, and getByText finds the heading by its visible text. When the assertion passes, a user would see the greeting in the page.

Test what the user does, not how the code works

The library name is about behavior. A counter test should click a button and read the changed text, not reach into a state variable. This component increments its count when clicked.

App.jsxApp.jsx
import { useState } from "react";
 
export default function Counter() {
  const [count, setCount] = useState(0);
 
  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

The test below describes the interaction and its visible result, so it never reads the count variable directly. It clicks the button by its role and name, then reads the label that appears afterward.

App.jsxApp.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter";
 
test("increases the count when clicked", async () => {
  const user = userEvent.setup();
  render(<Counter />);
 
  await user.click(screen.getByRole("button", { name: "Count: 0" }));
  expect(screen.getByRole("button", { name: "Count: 1" })).toBeInTheDocument();
});

If you later split the counter into two components or rename the state variable, the test still passes because the user still sees the same button text. The assertion reads the same label a screen reader would announce.

Why implementation tests break

An implementation test asserts on things users cannot see, such as a received prop object or a helper function call. It passes only while the internals are shaped one way and fails after an innocent refactor.

A test that clicks a button and reads the label survives that refactor because it checks what the user sees. Behavior is the contract that matters, because it is what a real user experiences.

Queries, events, and setup

Queries find elements, and user-event performs interactions. Both deserve their own focus, so the rest of this section covers them in detail.

For choosing the right way to find an element, follow how to query elements in React Testing Library. To simulate clicks, typing, and keyboard input, follow how to test user events in React. When you need the full toolchain wired up, follow how to test React components with Vitest.

Common mistakes

  • Testing state variables, props, or function calls instead of visible output.
  • Using a test ID when a role or label already matches.
  • Asserting the component instance instead of the rendered DOM.
  • Making one test cover every concern instead of one behavior.

Use data-testid only as an escape hatch for elements with dynamic text or no accessible match. Most components can be tested entirely through roles, labels, and text.

What to learn next

Start with the queries article to pick the right selector for each element, then move to user events to drive interactions the way a real user would.

Rune AI

Rune AI

Key Insights

  • Assert on visible output and real interactions, not internal state or props.
  • Install @testing-library/react with @testing-library/dom as a dev dependency.
  • Use screen queries and userEvent to drive components the way a user would.
  • A test that survives a refactor proves it checks behavior, not internals.
  • Use data-testid only when no accessible query matches.
RunePowered by Rune AI

Frequently Asked Questions

Is React Testing Library a test runner?

No. It renders components and queries the resulting DOM, but it does not run tests. Pair it with a runner such as Vitest or Jest.

What does it mean to test implementation details?

It means asserting on things users cannot see, such as state values, prop objects, or specific internal component names. Those assertions break when you refactor the internals without changing behavior.

Conclusion

React Testing Library keeps tests on the user's side of the screen. Render a component, interact with it through accessible queries and user events, and assert on what appears.