Part of the Testing React Like a User series. Part 1 is why a passing suite can lie to you; Part 2 is what to test in the first place, and how much.
The short version, in case you need to get back to work: a test suite can go green while the app is broken, and go red while the app is fine, and both lies come from the same mistake—testing how a thing is built instead of what it does. The fix is a question you ask before each assertion, not a tool: would a person notice? The rest is the account of a support queue that taught me this the hard way, plus the query ladder that makes the question mechanical.
Let’s start with that support queue. Tickets come in, somebody picks one up, clicks Assign, chooses a name, and moves on. I open the dialog, choose a name, click the button.
Nothing happens.
I click it again, with the same result. The feature is broken, and not in some edge case—assigning a ticket is the main thing this screen exists to do.
Now look at the test suite for that app:
# thirty-four tests, five files, not one of them redTest Files 5 passed (5) Tests 34 passed (34)
All files | 98.97 | 90.14 | 88.23 | 98.97Thirty-four passing tests. Ninety-nine percent coverage. A broken app.
I want to be careful about what I’m claiming here, because the obvious reading is wrong. Nobody wrote a bad test. Each of those thirty-four is a test I’ve written, that you’ve written, that sails through code review on a Tuesday. They’re reasonable tests, and each one is looking slightly to the left of the thing that matters.
The user you invented
Kent C. Dodds has a framing I haven’t been able to un-see. Any component you write has two users.
There’s the end user, who clicks things and reads things and tabs through things. And there’s the developer user, who imports your component, passes it props, and renders it somewhere. Both audiences can complain, and when they do, you listen.
Then there’s a third one, the one you invented: the tests themselves. Tests can reach places neither of those users can reach. They can read state. They can replace a child component with a stub. They can grab a callback out of a mock and invoke it directly. And the moment a test depends on one of those things, that thing is part of your contract. You have to keep it working for a user who doesn’t pay the bills.
The tests become the roommate who was never on the lease and never splits the
rent, but somehow gets a vote on the furniture. You didn’t mean to give the tests
that power. You granted it one querySelector at a time.
That gives us a working definition worth writing down:
An implementation detail is anything the users of your code do not use, see, or even know exists.
State variables, class names, which component is nested inside which, the name of a callback prop—all invisible to both users, and all fair game for a test to bind itself to.
What the passing tests were actually asserting
So what did those thirty-four tests check? Here are three of them, and I’d like you to notice how normal they look.
// The child component is replaced with a stub before the test runsvi.mock('./AssignDialog', () => ({ AssignDialog: vi.fn(() => <div data-testid="assign-dialog" />),}));The dialog is gone, swapped for a stub. Whatever the dialog does or fails to do, this test will never find out, because the real one isn’t in the room.
// The callback is pulled out of the mock and invoked by handconst props = mockedDialog.mock.calls.at(-1)?.[0];await props?.onAssign('Blake', 'Taking this one');Read that second line the way a user would experience it: the test reaches past the button and pulls the lever directly. That’s testing a vending machine by opening the back panel and shoving the candy bar down the chute yourself—the machine “works” each time you try it. So the question is the button connected to this function? wasn’t one this test failed to answer. The test was built never to ask it.
// Rows are counted by CSS classexpect(container.querySelectorAll('.ticket-row')).toHaveLength(4);Four rows, with no idea which four or in what order.
And the bug was one attribute:
// type="button" instead of type="submit" — the form never submits<button type="button">Assign ticket</button>Each test that touches the assign flow goes around that button rather than through it. The suite isn’t wrong about anything it says. The suite just never gets around to saying the thing that broke.
The second bug, and the number that hid it
There’s a second defect in that build, and for a triage tool the second one is worse: the queue isn’t sorted. Urgent tickets aren’t at the top. They’re in whatever order the server sent them.
The sorting function’s coverage row looks like this:
sortTickets.ts | 100 | 100 | 100 | 100One hundred percent, each branch covered: empty array, single item, tie-breaking on timestamp, doesn’t mutate its input. These are good tests, and I’d approve them.
The bug is that the queue component doesn’t call the function.
This is the gap a coverage dashboard can’t show you. Coverage answers one question—did a test execute this line? Coverage can’t tell you whether the line is reachable by a user, whether the behavior appears anywhere on screen, or whether you built the right thing at all. Kent’s phrase for the difference is the one to keep: code coverage is not use case coverage. (Part 2 puts that phrase to work; here it just gets a name.)
I want to be fair to coverage, however, because it really is a useful instrument. It’s a smoke detector: inexpensive, always on, and very good at its one job—chirping about a room you forgot you had. For example, an uncovered line in code you wrote is worth a look. But a smoke detector can’t tell you the extinguisher is empty or that the back stairwell is stacked with boxes. A smoke detector isn’t a fire safety plan, and we’ve spent years treating the beeping box on the ceiling like it was one.
The other way a suite lies
The cases so far are a suite staying green while the app is broken. Now the opposite, which is the failure that really eats your team’s afternoons.
For example, I changed the queue component. Three useState calls became one
useReducer. The row markup moved into a separate TicketRow component. I
renamed a couple of CSS classes while I was in there, and the hook renamed
visible to rows. A standard Tuesday afternoon tidy-up. Same screen, same
clicks, same behavior—no change a person using the app could possibly notice.
FAIL Coverage-driven suite 11 failed | 23 passedPASS User-centric suite 13 passedEleven failures. Nothing broke. Six of them because a variable inside a hook got a better name. One because a test about ticket counts was reading a CSS class.
So we have two failure modes, and they share a cause:
| Test says PASS | Test says FAIL | |
|---|---|---|
| App works | correct | false negative—you lose an afternoon |
| App is broken | false positive—the bug ships | correct |
When a test couples itself to internals, it stops tracking behavior. So the test goes green when behavior breaks, and red when behavior doesn’t. You’ll get noise in both directions, and a suite that produces noise in both directions slowly stops being read at all. The team learns that red means “go look at the test”, not “go look at the app”. At that point the suite may have stopped being worth its maintenance cost.
There’s a sharper way to say this. Martin Fowler’s definition of refactoring is changing the structure of code without changing its behavior—which assumes you have some way of knowing the behavior didn’t change. If your tests read internals, you don’t have that check. You can’t refactor with any confidence—you rewrite and hope.
Asking a different question
Same component, same app, different question: not did this function get called, but would a person notice.
// The whole assign flow, performed the way somebody performs itawait user.click(screen.getByRole('button', { name: 'Assign T-1043' }));
const dialog = screen.getByRole('dialog', { name: /assign t-1043/i });await user.selectOptions( within(dialog).getByRole('combobox', { name: /assign to/i }), 'Blake',);await user.type( within(dialog).getByRole('textbox', { name: /note/i }), 'Taking this one',);await user.click(within(dialog).getByRole('button', { name: /assign ticket/i }));
expect(await screen.findByText('T-1043 assigned to Blake')).toBeInTheDocument();Read the test aloud and it’s just the ticket: click assign, pick Blake, type a
note, submit, see that it worked. No line in there knows that a hook called
useTickets exists, that rows carry a class, or that the dialog lives in a
separate file. All three can change tomorrow.
Run that suite against the broken build and you’ll see it fail four times, with
the failure output printing the DOM and type="button" sitting right there in
it. Run it against the refactor and all thirteen will stay green.
Find things the way they do
The mechanism that makes this work is the query you reach for. Testing Library publishes a priority order, and that order deserves to be treated as the actual recommendation rather than a stylistic preference:
getByRolewith a name—how a person and a screen reader both find things, and your first choice for almost anything on the page.getByLabelText—form fields.getByText—static content.getByAltText/getByTitle—images, and the occasional gap.getByTestId—last. The user cannot see or hear it.
// Reaching into the DOM for a class nobody outside the codebase knows aboutconst button = container.querySelector('.submit-btn');
// Finding it the way a person does — and the way a screen reader doesconst button = screen.getByRole('button', { name: /assign ticket/i });Start at the top and go down a rung only when the one above can’t reach the
element. getByTestId isn’t banned. Charts, for example, or virtualized rows,
canvas elements, and text that changes on each render are legitimate reasons to
reach for it. The problem is reaching for it first.
There’s a side effect that’s worth the price of admission by itself. If you can’t find a control by its role and its accessible name, a screen reader user hears an unlabeled control too. Your suite will start failing on accessibility regressions without anyone deciding to test for accessibility, which is a pretty good deal for one query choice.
What I actually want you to take from this
Not that your test suite is garbage and should be rewritten—rewriting a suite is expensive and the payback is slow.
Three things I’d like you to take away instead.
A test can lie in two directions, and both lies come from the same mistake: describing how something is built rather than what it does.
The fix is a question, not a tool. Before you write the assertion, ask
whether a person would notice if it stopped being true. The rest—screen,
userEvent, the query ladder—is mechanics in service of that question.
Start with the next test. Not the backlog—the next one you write.
The obvious follow-up is which tests to write at all—how many, at what level, and what to do about the coverage number your CI pipeline is still enforcing. Part 2 is the Testing Trophy, use case coverage, mocking at the network boundary with MSW, and the honest answer to an eighty-percent gate, which isn’t “delete it”.
Related
- Designing Good React Components—components with clear contracts are the ones that are pleasant to test
- Designing Good Custom React Hooks—and why testing one in isolation is usually the wrong instinct
- End-to-End Testing an Astro Production Build—the same argument, one level up the stack