A glossy 3D vending machine with a hand pressing its front button under green-cyan light while a robotic arm reaches through the open back panel to pull the lever directly under red light, beside the title Testing React Like a User, Part 1: The Third User.

Testing React Like a User, Part 1: The Third User

Part 9 of 10 in React Lunch & Learn

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:

Terminal window
# thirty-four tests, five files, not one of them red
Test Files 5 passed (5)
Tests 34 passed (34)
All files | 98.97 | 90.14 | 88.23 | 98.97

Thirty-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 runs
vi.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 hand
const 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 class
expect(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:

Terminal window
sortTickets.ts | 100 | 100 | 100 | 100

One 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.

Terminal window
FAIL Coverage-driven suite 11 failed | 23 passed
PASS User-centric suite 13 passed

Eleven 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 PASSTest says FAIL
App workscorrectfalse negative—you lose an afternoon
App is brokenfalse positive—the bug shipscorrect

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 it
await 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:

  1. getByRole with a name—how a person and a screen reader both find things, and your first choice for almost anything on the page.
  2. getByLabelText—form fields.
  3. getByText—static content.
  4. getByAltText / getByTitle—images, and the occasional gap.
  5. getByTestId—last. The user cannot see or hear it.
// Reaching into the DOM for a class nobody outside the codebase knows about
const button = container.querySelector('.submit-btn');
// Finding it the way a person does — and the way a screen reader does
const 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”.