AniUI Academy

Testing React Components

The behavior-first testing philosophy behind React Testing Library — render, query like a user would, interact, assert — and why testing internal state makes tests brittle.

8 min read

Everything in this course so far has been about writing components. This lesson is a conceptual overview of testing them — not a full testing course, but enough to recognize the dominant philosophy and avoid the most common way component tests go wrong.

The core idea: test behavior, not implementation

React Testing Library (built on top of a general DOM testing library, and the de facto standard for testing React components) is built around one principle, stated directly in its own documentation: "the more your tests resemble the way your software is used, the more confidence they can give you." Concretely, that means:

  • Render the component, the same way it would render in the real app.
  • Find elements the way a user would — by visible text, label, or accessible role — not by reaching into component internals or matching a CSS class picked for styling reasons.
  • Interact the way a user would — click, type, submit — using simulated real events.
  • Assert on what's visible or what happened — the text on screen, whether an item was added — not on a component's internal state variable.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
 
test("clicking the button increments the count", async () => {
  render(<Counter />);
 
  const button = screen.getByRole("button", { name: /clicked 0 times/i });
  await userEvent.click(button);
 
  expect(screen.getByText(/clicked 1 times/i)).toBeInTheDocument();
});

Notice this test never imports Counter's internals, never checks a useState value directly — it interacts with the rendered button exactly like a person would, and asserts on the text that person would actually see.

Why implementation details make tests brittle

Imagine testing the same counter by reaching into its state instead:

// Don't do this — coupled to an implementation detail
test("count starts at 0", () => {
  const wrapper = shallowRender(<Counter />);
  expect(wrapper.state.count).toBe(0); // depends on useState specifically
});

If Counter is later refactored to use useReducer instead of useState — a change with zero effect on what the user sees or can do — this test breaks anyway, for a reason that has nothing to do with an actual regression. A test that instead asserted "the button says 'Clicked 0 times'" would keep passing through that exact refactor, because the user-visible behavior didn't change. This is the entire argument for behavior-first testing: it lets you refactor internals freely as long as behavior is preserved, which is usually exactly what you want a test suite to protect.

Querying by role and label doubles as an accessibility check

Preferring getByRole and getByLabelText over getByTestId or querying CSS classes isn't just a testing style choice — if a button or form field can't be found by its accessible role or label, that's frequently a real accessibility gap (missing semantic markup, a missing <label>) worth fixing in the component itself, not just an inconvenience for the test.

Snapshot testing, and its real caveat

A snapshot test records a component's entire rendered output the first time it runs, and fails on any later test run where the output differs — useful for catching accidental changes to something you didn't mean to touch. Its real caveat: a snapshot records that something changed, not whether the change was correct. A team culture of reflexively running "update snapshots" without reading the diff can rubber-stamp a genuine regression as the new expected baseline — snapshots are best paired with actually reading what changed, not treated as a fully automated safety net.

What to remember

  • React Testing Library's philosophy: test components the way a user experiences them — render, query by role/text, interact, assert on what's visible.
  • Testing internal state or implementation details directly makes tests brittle — they break on refactors that don't change actual behavior, which defeats the point of having them.
  • Preferring accessible queries (role, label) over test-only ids doubles as a real accessibility check on the component itself.
  • Snapshot tests catch unintentional changes but don't judge correctness — they're only useful paired with actually reviewing the diff, not blindly accepting it.

Check yourself

4 questions · pass 3/4 to unlock Capstone: A Small, Real Component

up to 50
  1. 1.What is the guiding principle behind React Testing Library's approach to testing components?

  2. 2.Why does testing a component's internal state directly (e.g. asserting that some internal useState value equals 3) tend to produce brittle tests?

  3. 3.In React Testing Library, how do you typically find an element to interact with or assert on?

  4. 4.What's the main caveat with snapshot testing a component's full rendered output?

4 left to answer