Concept
The beginner framing: testing a React component means rendering it in a simulated environment, interacting with it the way a user would (clicking, typing), and asserting that the right thing appears on screen.
The precise mental model: React Testing Library's guiding principle is "test like a user", query for elements the way a real user (or an assistive technology like a screen reader) would find them, by role and accessible text, not by digging into implementation details like CSS class names or a component's internal state shape. A test written this way keeps working through a purely visual or internal refactor, and breaks only when the actual user-facing behavior changes, which is exactly the property a good test suite should have.
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: /increment/i }); // query like a USER would
await userEvent.click(button); // simulate a REAL interaction, not a raw DOM event dispatch
expect(screen.getByText("Count: 1")).toBeInTheDocument(); // assert on visible OUTPUT
});Nothing here references Counter's internal state variable name, its component structure, or a CSS class, the test finds the button the way a user would (its accessible role and label) and asserts on what the user would actually see.
Testing custom hooks: renderHook
Hooks can't be called outside a component (see Hooks Overview), so testing one directly requires a minimal wrapper that gives it somewhere valid to run:
import { renderHook, act } from "@testing-library/react";
import { useToggle } from "./useToggle"; // from Custom Hooks
test("useToggle flips its value", () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current[0]).toBe(false);
act(() => result.current[1]()); // call the toggle function returned by the hook
expect(result.current[0]).toBe(true);
});renderHook mounts a minimal, invisible component internally purely so the hook has a valid place to run, this is a direct, practical consequence of the Rules of Hooks: there's no way to call useToggle() as a bare function outside of a component's render.
Mocking at the network boundary, not internal modules
// Mock the actual HTTP layer (e.g. with MSW), not your own component's internals:
server.use(
http.get("/api/user/:id", () => HttpResponse.json({ name: "Ada" }))
);
test("shows the fetched user's name", async () => {
render(<UserProfile userId="1" />);
expect(await screen.findByText("Ada")).toBeInTheDocument();
});Mocking at the network boundary means the component's actual data-fetching code (whether a raw fetch, useEffect, or a query library from Data Fetching) runs for real, only the underlying HTTP response is faked. This exercises the real request/response contract instead of assuming your own fetching logic works correctly by mocking it away entirely.
Try It
Predict what happens before checking the solution.
// Test A, queries by CSS class:
const button = container.querySelector(".btn-primary");
// Test B, queries by accessible role:
const button = screen.getByRole("button", { name: "Save" });A designer later renames .btn-primary to .btn-cta purely as a CSS refactor, with no change to the button's visible text or behavior. Which test breaks?
Solution
Test A breaks, it was coupled to an implementation detail (a CSS class name) that changed for purely cosmetic reasons, unrelated to anything a real user would notice. Test B keeps passing, because the button's accessible role and visible label never changed, exactly the "survives a refactor that shouldn't break anything user-facing" property that querying by role/text is meant to provide.
Implement It Yourself
A minimal, dependency-free sketch of what render + screen-style querying is actually doing under the hood:
function render(Component, props) {
const container = document.createElement("div");
document.body.appendChild(container);
ReactDOM.createRoot(container).render(<Component {...props} />);
return { container };
}
function getByRole(container, role, { name } = {}) {
const candidates = container.querySelectorAll(`[role="${role}"], ${roleToTagSelector(role)}`);
const match =
This captures the essential idea: getByRole searches the rendered DOM the same way an assistive technology or a sighted user scanning for a labeled control would, by role and visible/accessible name, rather than by any internal implementation detail like a class name or component prop.
Under the Hood
renderHook's need for an internal wrapper component is a direct consequence of the Rules of Hooks covered in Hooks Overview, hooks are only valid when called during a component's render, tracked by React's internal bookkeeping of "which component is currently rendering," so testing one in isolation still requires a minimal, real component context to run inside. And "test like a user, not like an implementation" mirrors the exact same principle behind Rendering Lifecycle's emphasis on observable output over internal mechanics, a test asserting on rendered, visible behavior is testing the same thing a user (or a hydration check, or a reconciliation diff) ultimately cares about: what actually appears, not how it got there internally.
Common Mistakes
1. Querying by CSS class or test-id as the first resort
Covered in Try It, reach for accessible queries (getByRole, getByLabelText, getByText) first; fall back to a data-testid only when no accessible attribute meaningfully distinguishes an element, since a test-id-based query is just as coupled to an implementation detail as a class name, only slightly more stable.
2. Asserting on internal state instead of rendered output
expect(wrapper.state("isOpen")).toBe(true); // ❌ testing an internal implementation detailAsserting on a component's internal state variable (or a hook's return value from outside a proper renderHook context) couples the test to implementation details that can change even when the user-visible behavior doesn't, assert on what's actually rendered (screen.getByText(...), toBeVisible()) instead.
3. Mocking too deep, faking your own component's internal modules
jest.mock("./useUserData"); // ❌ mocks your OWN hook, meaning the real data-fetching logic is never exercisedMocking your own internal hook or module means the actual logic connecting your component to its data source is never actually tested, only that the component renders correctly given whatever the mock returns. Mocking at the network boundary (see Concept) tests the real internal wiring, faking only the parts genuinely outside your control (the server's response).
Best Practices
- Query by role, label, or visible text first, fall back to
data-testidonly when nothing accessible distinguishes an element. - Use
userEventoverfireEventfor interactions, it simulates the fuller sequence of real events a browser would fire (focus, pointer events, etc.), not just a single synthetic event. - Mock at the network boundary, not at your own component's internal modules, so the real data-fetching and state-wiring logic is actually exercised by the test.
- Test custom hooks via
renderHook, since a hook can't be called as a bare function outside a component's render.
Performance Tips
- Avoid full-page renders in tests that only need a small, focused subtree, rendering less means faster individual tests and a faster overall suite.
- Use
within()to scope queries to a specific region of a large rendered tree, avoiding accidentally slow or ambiguous whole-document queries in tests covering complex pages. - Parameterize repeated test setups (
test.each) rather than duplicating near-identical render+assert blocks, reduces both maintenance burden and redundant setup cost across the suite.
