A glossy 3D bathroom scale reading 80%, lit red-orange on one side and cyan on the other, representing a test coverage number as a proxy that can be gamed.

Testing React Like a User, Part 2: What to Test, and How Much

Part 10 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; this is what to test in the first place, and how much.

In Part 1 a support queue had a broken assign button, thirty-four passing tests at ninety-nine percent coverage—and those facts were related. The fix was asking each test not did this function get called but would a person notice. This part asks the harder questions: which tests do you write at all, how many is enough, and what do you do about the coverage number your pipeline is enforcing on Monday morning?

The gist, if you only have a minute: spend most of your effort on integration tests (the Testing Trophy), write a plain-English list of what users do before you write a single test (use case coverage), mock the network instead of your own code (Mock Service Worker), and keep your coverage gate—just change what’s allowed to feed it. Details below.

The shape of a good suite

Kent C. Dodds’ Testing Trophy is the most useful picture I know for this. Let’s walk it from the bottom:

  • Static analysis—TypeScript and your linter. Free, runs as you type, catches an entire category of mistakes before a test exists.
  • Unit tests—a single function or module, in isolation.
  • Integration tests—several units working together: the component tree, the hook, the fetch, but no browser.
  • End-to-end tests—the full app, in a browser, like a person.

Two things climb together as you go up. Confidence rises, because the test looks more like the real thing. Cost rises too—slower to run, more work to write, a lot more annoying when it breaks. The trophy is the observation that the best ratio sits in the middle. Integration tests buy the most confidence per unit of pain, so that’s where most of your effort belongs.

The user-centric suite from Part 1 has this shape. That suite renders the component tree, runs the hook, makes real fetch calls, and parses the responses. Thirteen tests, and they caught both bugs that thirty-four isolated ones missed.

There’s a consequence people skip past. When your integration tests exercise the units, you often don’t need to test the units separately at all. Testing that the pieces work together tends to make the isolated tests redundant rather than complementary. Most suites I’ve seen carry both, pay for both, and get the confidence of one.

What to do about the coverage gate

Most teams I’ve worked on have a number in CI. Ours is eighty percent, and I’d guess yours is somewhere between seventy and eighty-five.

Now the uncomfortable part. Ask a room of engineers who’s written a test purely to get a file over the line, and most of the hands go up, including mine. That isn’t a moral failure. That’s Goodhart’s Law doing what Goodhart’s Law does:

When a measure becomes a target, it ceases to be a good measure.

The textbook version is the call center that started grading agents on average call length. Within a month, agents were hanging up on customers mid-sentence. Nobody hired villains—the metric just made hanging up the easiest way to look good, and once a number is the goal, easy wins.

The instant coverage became the goal, the same thing happened to us. The easiest way to move coverage is to test units in isolation—render the hook by itself, stub the child, poke the setters, assert on the returned object. Which is precisely the practice that produced the ninety-nine percent broken app in Part

  1. The gate did worse than fail to prevent that outcome. The gate selected for it.

So the obvious conclusion is to delete the gate, and I don’t think that’s right either. A gate at zero is worse than a gate at eighty. The gate isn’t the problem. What’s easiest to do underneath the gate is.

Three changes, none of which require touching the threshold:

Let integration tests earn the number. Same eighty percent, different source. Our thirteen user-centric tests clear it by themselves, with not a single isolated component test among them. If integration tests generate the coverage, the easiest path to the number is also the path that produces confidence.

Decide your exclusions deliberately. Type declarations, the mock layer, the app shell, and generated files, for example. Most repos I’ve opened have never made this decision—the exclusion list is whatever the default was in the tool they picked. Make it a choice, write it down, and revisit it.

Move the question in code review. Stop asking “is it at eighty?” and start asking “what does this let a user do, and is each of those things named in a test?” The number stays in CI where it belongs, as a floor rather than a target.

Write the list first

Which brings us to the actual technique, and it’s unglamorous enough that people skip it: before you write any tests, write the list—not of functions, of things a person does.

For the queue from Part 1, for example, said out loud in about ninety seconds:

  • See what is waiting, most urgent first
  • Find a ticket by subject or requester
  • Filter to one status
  • Assign a ticket to somebody
  • Know when an assignment failed
  • Know when the queue cannot load
  • Not be confused when the queue is empty

Seven of them. Now let’s go back to the ninety-nine percent suite and check which of those it verified. The answer is two, and both partially.

That gap is what Kent calls use case coverage, and no tool measures it—no dashboard, no CI check, no badge. Use case coverage is a list you write by hand, and skipping that list is why a coverage number can be high and meaningless at the same time.

For a full feature rather than one component, sort the list by what would upset users most if it broke, and start at the top. Happy paths for the highest-value flows first, then edge cases and error states. You’ll run out of time before you run out of list, which is the point of sorting it.

None of those seven lines mentions state, hooks, or components. The omission is deliberate, and it’s the reason the list survives you rewriting the code underneath it.

To be clear about where unit tests still belong: pure functions with meaningful branching deserve them. Sorting, parsing, and date math, for example—anything where the interesting part is the logic rather than the screen. The sorting function in Part 1 had thorough unit tests and should have. The mistake was never that the sort was unit tested. The mistake was that the sort was only unit tested, while the queue on screen never called it.

Mock the network, not your own code

Each test in that user-centric suite makes a real fetch call. None of them mock fetch, and none of them mock our own modules. What they do instead looks like this.

// src/mocks/handlers.ts — one definition of what the API does
export const handlers = [
http.get('/api/tickets', () => HttpResponse.json(tickets)),
http.patch('/api/tickets/:id/assignee', async ({ params, request }) => {
const { assignee, note } = await request.json();
return HttpResponse.json({ ...find(params.id), assignee, lastNote: note });
}),
];
// scenario overrides, applied per-test with server.use(...)
export const assignFails = http.patch(
'/api/tickets/:id/assignee',
() => new HttpResponse(null, { status: 500 }),
);
src/test/setup.ts
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Mock Service Worker (MSW) intercepts at the network layer, and the difference from the alternative isn’t stylistic. Consider what vi.mock('./api') does: it replaces your module. Your fetch call, your status check, your JSON parsing, your error handling—none of that runs. What remains is an assertion that a stub you wrote returned the value you told it to return—a tautology with a green tick next to it.

MSW replaces the server. The code on your side of the wire runs for real, which is the side you’re shipping.

onUnhandledRequest: 'error' is the line that earns its keep. A request without a handler will fail loudly, right there, instead of resolving to undefined and surfacing as a baffling assertion failure four lines later. That one option makes tests noisy in the one direction you want.

The practical win is that error and loading states stop being the things that go untested. They become one line each:

// The failure path, which used to be too much work to bother with
server.use(assignFails);
await user.click(screen.getByRole('button', { name: /assign ticket/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/could not assign/i);

I’m making MSW a convention rather than a technique, so the cost deserves stating plainly: handlers drift from the live API unless someone maintains them. That drift is a maintenance cost. However, it’s still better than the alternative, where each test file writes a private fiction about what the server does—a little fan fiction of the API, each one plausible, none of them canon, all of them drifting apart independently. At least with MSW there’s one script to keep true.

One definition also means one set of handlers can back your test suite, your dev server, and your end-to-end run. The demo app I built for this has no backend at all—npm run dev is served entirely by the same handlers the tests use.

Writing the tests before the feature

Once you have a use-case list, test-driven development stops being an act of discipline and becomes the obvious next step. The list is already a specification. Turning each line into a failing test is transcription—the way a grocery list becomes a cart. You’re not deciding anything at the shelf; you decided in the kitchen.

The one I’d draw attention to, from a list for an optimistic assignment feature—optimistic meaning the screen updates immediately and trusts the server to agree later:

Put it back when the server refuses. If the request fails, the previous assignee returns.

This is the use case most people forget, and the revert has a specific trap. A test that checks the error message appears will pass whether or not the screen reverts—two separate guarantees, one of which is invisible unless you name it. Which is a good rule to follow generally: for each use case, ask which of your tests would fail if that specific behavior were deleted. If the answer is none, you’ve written a description rather than a test.

A short list of honest caveats

  • User-centric tests run slower. Thirteen integration tests take longer than thirty-four isolated ones. You’re trading run time for knowing whether the app works, and I think that’s pretty clearly the right trade, but it’s a trade.
  • data-testid is still the last rung, not banned. The exceptions are in Part 1’s query ladder; I won’t repeat them here.
  • Prefer findBy* to waitFor(() => getBy*). Same result, better failure message.
  • act() warnings usually mean a missing await. Find it rather than wrapping things in act to silence the warning.
  • Every list surface deserves three tests: empty, some, lots. The empty state is a screen somebody designed. The “lots” case is where layout breaks down.
  • Tests are production code. Fixtures at the top, named helpers, clear sections. A test file that’s hard to read is a bug in the test file.

The throughline

Part 1 argued that a test can lie in two directions, and both lies come from describing how code is built rather than what it does. This part is the same idea one level up.

The coverage number on your dashboard was never the thing you wanted. The number is a proxy—a bathroom scale for your test suite. The scale is an inexpensive, automatable stand-in for the expensive question of whether you’re healthy, and the scale is useful right up until you start optimizing the reading directly. At that point skipping breakfast and weighing in dehydrated become the fastest path to progress, and the number improves while the thing it stood for doesn’t.

Write the list. Test the list. Let the number follow.