Testing a component

Test a component the way a person uses it, and know which tests are worth writing at all.

20of 20
≈ 11Min read
9Parts

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

what each one is for
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.

render, act, assert
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

QueryUse for
getByRole(role, { name })First choice for everything. Buttons, headings, inputs, links, alerts
getByLabelTextA form field, when the role is awkward
getByPlaceholderTextOnly when there is genuinely no label
getByTextPlain content — a paragraph, a cell
getByTestIdThe last resort, for something with no accessible handle at all
A query that is hard to write is a bug

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.

three prefixes, three meanings
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.

what { name: … } matches against
<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.

intercept the request, not your own module
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.

Practice