The rule
The more your tests resemble the way your software is used, the more confidence they give you. That one sentence, from the author of Testing Library, decides almost every question below.
- Find things the way a user finds them: by role, by label, by visible text.
- Act the way a user acts: click, type, tab.
- Assert what a user would notice: text on the screen, a disabled button, a message.
- Never assert on state, props, hook calls or class names. Those are how it works, not what it does.
The tools
vitest / jest the runner and the assertions
@testing-library/react render, screen, and the queries
@testing-library/user-event realistic typing, clicking, tabbing
@testing-library/jest-dom matchers: toBeVisible, toBeDisabled, toHaveAccessibleName
msw intercept network calls, at the network layer
A whole test
No mention of useState, no shallow rendering, no component instance. If the implementation is rewritten with a reducer tomorrow, this test still passes — which is exactly what a test is for.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("adds a door to the order", async () => {
const user = userEvent.setup();
render(<Order />);
await user.click(screen.getByRole("button", { name: "Add door" }));
expect(screen.getByRole("status")).toHaveTextContent("1 in the order");
});
Queries, in the order you should reach for them
| Query | Use for |
|---|---|
getByRole(role, { name }) | First choice for everything. Buttons, headings, inputs, links, alerts |
getByLabelText | A form field, when the role is awkward |
getByPlaceholderText | Only when there is genuinely no label |
getByText | Plain content — a paragraph, a cell |
getByTestId | The last resort, for something with no accessible handle at all |
If nothing can be found by role and name, a real user with a screen reader cannot find it either. Fixing the markup fixes both at once.
get, query and find
Using getBy to assert absence is the most common mistake: it throws before your expectation ever runs.
getByRole("button") // must be there now — THROWS if missing
queryByRole("button") // may be missing — returns null
await findByRole("button") // will be there soon — waits, then throws
// so:
expect(screen.queryByRole("alert")).not.toBeInTheDocument(); // asserting absence
expect(await screen.findByText("Saved")).toBeVisible(); // after a request
The accessible name
The name comes from the same computation a screen reader performs: aria-label, then aria-labelledby, then the associated <label>, then the element’s own text.
<button>Add door</button> // "Add door"
<button aria-label="Close">×</button> // "Close"
<label for="qty">Quantity</label><input id="qty"> // "Quantity"
<img src="…" alt="Painted door"> // "Painted door"
What is worth testing
Not worth testing: that a heading contains the text you just put in it, that a library works, that a component renders without crashing (it will crash in the next test anyway), or anything in a snapshot nobody reads before approving.
- Behaviour a user depends on: submitting a form, validation appearing, a list filtering, an error and its retry.
- Pure logic, separately and exhaustively: formatting, validation, reducers. These are cheap and they catch the most bugs per line.
- Bugs, once: the regression test is the cheapest test you will ever write.
The network
Mocking fetch itself, or the module that wraps it, ties the test to your plumbing. Intercepting at the network layer lets the component do exactly what it does in production.
import { http, HttpResponse } from "msw";
const server = setupServer(
http.get("/api/doors", () => HttpResponse.json([{ id: 1, name: "Painted" }]))
);
// and the failure path, which is the one that is never tested
server.use(http.get("/api/doors", () => new HttpResponse(null, { status: 500 })));
The shape of a suite
- A lot of pure unit tests on
lib/and reducers — milliseconds each. - A handful of component tests per feature, on the paths that matter.
- A few end-to-end tests (Playwright) on the journeys that must never break: sign in, check out.
- Coverage is a smoke alarm, not a goal. 100% coverage of code nobody uses tells you nothing.