Write component tests with React Testing Library that find elements like a user and fail when behaviour breaks.
Why it matters
Tests let you change code without fear. A component test renders a component, interacts with it like a user and checks what appears on screen. Teams expect pull requests to include tests, and many technical interviews include writing one. React Testing Library (RTL) is the standard tool for this.
The guiding principle
The more your tests resemble the way your software is used, the more confidence they can give you.
That idea from the Testing Library docs shapes everything. Users do not see component state, prop names or CSS classes. They see text, buttons, labels and headings. So tests should find elements the way a user (or a screen reader) would, and should not reach into implementation details. A good test keeps passing when you refactor the internals, and fails when behaviour breaks.
The tools
A typical setup in a Vite project uses Vitest (or Jest) to run tests, jsdom to simulate a browser, @testing-library/react to render components, @testing-library/user-event to simulate realistic typing and clicking, and @testing-library/jest-dom for readable assertions such as toBeInTheDocument().
// Counter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { test, expect } from "vitest";
import Counter from "./Counter";
test("adds one when the button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Add one" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
The structure is Arrange, Act, Assert: render the component, do something a user would do, check the result.
Queries
Queries find elements. They come in three flavours:
| Prefix | If not found | Use for |
|---|---|---|
getBy... | throws | elements that should be there now |
queryBy... | returns null | asserting something is absent |
findBy... | waits, then rejects | elements that appear later (after a fetch) |
Each also has an ...AllBy... form that returns an array.
And they come in priority order. Prefer the ones at the top:
getByRolewith a name:getByRole("button", { name: "Save" }),getByRole("heading", { name: /profile/i }),getByRole("textbox", { name: "Email" }). This also checks your markup is accessible: if a button has no accessible name, the test cannot find it.getByLabelTextfor form fields.getByTextfor non-interactive content.getByTestIdonly when nothing else works.
screen is a set of queries bound to the whole document. render also returns queries bound to its own container, which is what the Do exercise uses so several tests can run side by side.
Interactions and async UI
userEvent simulates real sequences of events: await user.type(input, "Ada") fires key, input and change events for each character; await user.click(button) includes pointer and focus events. The lower-level fireEvent.click(el) dispatches a single event and is fine for simple cases.
When the UI updates after a promise, wait for it:
test("shows users after loading", async () => {
const load = () => Promise.resolve([{ id: 1, name: "Ada" }]);
render(<UserList load={load} />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(await screen.findByText("Ada")).toBeInTheDocument();
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});
Passing a fake load function keeps tests fast and offline. For components that call fetch directly, tools such as Mock Service Worker (MSW) intercept requests instead.
A test must be able to fail
A test that passes no matter what is worse than no test, because it gives false confidence. Check yours by breaking the code on purpose: change count + 1 to count + 2, or rename the button, and confirm the test goes red. The Do exercise does exactly this to your test.
What to test
- What the user sees for each important state: empty, loading, error, full.
- Interactions: clicking, typing, submitting, and the results.
- Callbacks: that
onSavewas called with the right data (vi.fn()creates a spy). - Edge cases that broke before (write a test with every bug fix).
What not to test: internal state values, private functions, exact CSS, or React itself. Pure logic such as reducers is easier to test directly as functions, as you did in module 2.
Common mistakes
- Querying by class name or component internals, so tests break on harmless refactors.
- Using
getByto assert absence (it throws); usequeryByand expectnull. - Forgetting
awaitwithfindByoruserEvent, so assertions run too early. - Snapshot tests of whole pages that everyone updates without reading.
Recap
- RTL renders components and finds elements the way users do; prefer
getByRolewith a name. getByfor present,queryByfor absent,findByfor later.- Arrange, act, assert; use
userEventfor realistic interactions andawaitasync UI. - Prove each test can fail by breaking the code on purpose.
Try it
Open the Do tab to write testCounter, a component test that must pass on the real counter and fail on three broken ones.