Lesson 30 of 31
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.
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
1.What is the guiding principle behind React Testing Library's approach to testing components?
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.In React Testing Library, how do you typically find an element to interact with or assert on?
4.What's the main caveat with snapshot testing a component's full rendered output?
4 left to answer