Testing React components — render, query, interact, assert
Testing Library's entire philosophy is one sentence — test what a user can actually see and do, not how the component is built internally — and nearly every one of its API design choices exists to enforce that one rule.
3 min read
The two ways to test a component, and why one of them is a trap
// Implementation-detail testing — reaches into the component's INTERNALS
test("increments the count", () => {
const wrapper = shallowRender(<Counter />);
wrapper.instance().setState({ count: wrapper.instance().state.count + 1 }); // calls internal state directly
expect(wrapper.instance().state.count).toBe(1);
});
// User-behavior testing — interacts the way a REAL USER would
test("increments the count when the button is clicked", () => {
render(<Counter />);
fireEvent.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});The first version tests Counter's internal implementation — its state shape, its internal method names — none of which a real user or a real caller of this component ever touches directly. If Counter is later refactored to use a reducer instead of useState, with the exact same visible behavior, the first test breaks anyway (wrong reason — nothing user-visible changed) while the second test keeps passing (right reason — it never depended on how the count is tracked internally, only on what a user can see and click). This is Testing Library's central, deliberate design bet: a test that survives a refactor which doesn't change behavior, and fails when behavior actually changes, is strictly more valuable than one that does the opposite.
Querying the way a user actually finds things — not by implementation details
// Fragile — depends on a CSS class name nobody using the app ever sees
const button = container.querySelector(".btn-primary-42");
// Robust — queries the way a real user (or a screen reader) actually identifies it
const button = screen.getByRole("button", { name: "Submit order" });Testing Library's query functions are deliberately ordered by priority toward how an actual user (including a user relying on assistive technology) identifies an element: by its accessible role and name first, then by label text, then by visible text — and only as a last resort by an opaque test ID, with raw CSS selectors actively discouraged. Querying by a CSS class or internal test-only attribute means the test can break on a pure styling refactor that changes nothing about what a user sees or can do — exactly the kind of false-alarm failure that erodes trust in a suite, the same problem the flaky-tests lesson covers from a different angle.
Interacting, then asserting on what actually changed for the user
test("shows a validation error for an empty email field", async () => {
render(<SignupForm />);
await userEvent.click(screen.getByRole("button", { name: "Sign up" }));
expect(await screen.findByText("Email is required")).toBeInTheDocument();
});userEvent (Testing Library's higher-fidelity interaction helper, built on top of fireEvent) simulates a real user's click as a full sequence of real events — not just a single synthetic click event — which catches more real bugs than the lower-level fireEvent in components with more complex event handling. findByText, not getByText, is deliberate here too: the validation error likely appears after an async state update, so the query needs to wait for it to appear (the same async-testing pattern the earlier lessons on flaky and async tests already covered) rather than assuming it's already in the DOM the instant the click handler returns.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What's Testing Library's central design philosophy?
2. Why does Testing Library prioritize querying by accessible role and name over CSS class selectors?
3. Why use `findByText` instead of `getByText` when checking for a validation error after a form submit?