This article is published in English.
Beyond the Coverage Percentage: Testing the Failures Users Actually Hit
Why a high code coverage number can hide untested failure modes, the three kinds of vanity tests it encourages, and how to write tests that guard real behavior.
A pull request with every test green and a coverage report above 98% feels safe. Then an API returns null where the UI expected an object, a request resolves out of order on a slow mobile connection, or someone double-clicks the submit button, and the front end collapses into a blank screen. The post-mortem question is always the same: how did this break when the file was covered? This article explains what coverage really measures, the test patterns that inflate it without adding protection, and how to shift your suite toward the failures that matter.
What coverage measures, and what it does not
A coverage tool records which lines, branches and functions executed while your tests ran. That is all. It does not know whether any assertion checked the result, whether the inputs resembled real traffic, or whether the code behaves correctly when a dependency misbehaves. Executed and verified are two different properties, and coverage only reports the first.
A useful analogy is a building inspection in which someone walks through every room with a flashlight. Every room was visited, yet nobody tested the roof in a storm. High coverage tells you that your tests visited the code, not that they challenged it.
Three patterns that inflate coverage without adding safety
When a percentage becomes the target, people optimize for the percentage. The result is tests that satisfy the tool with minimal effort. Three shapes appear again and again.
The happy-path mirage
Picture a helper that parses user input and updates state. Its test passes a clean, well-formed string, checks the expected output and reaches full branch coverage. What it never tries is an empty string, unusual characters, an undefined argument, a slow response or malformed JSON. Every line ran, but none of the inputs that cause production incidents were exercised.
The over-mocked component
Here every API call, context provider and nested child gets swapped for a stub. The suite finishes in a few milliseconds and covers every render branch. In production, though, the real endpoint returns a slightly different shape than the mock assumed, or a library changes how it emits events after an upgrade. The tests keep passing because they only ever talked to your own assumptions, and the first real integration happens in a user's browser.
Tests with no meaningful assertions
The weakest form shows up under strict mandates, such as a required 90% team-wide threshold. Tests call functions purely to register line hits and assert little or nothing. The report turns green while no check stands between the code and future logic changes.
How to test behavior instead of lines
Tests that catch bugs before users do focus on what the system does, not on which lines it touches.
- Test states and transitions, not individual functions. Users do not care that a helper ran. They care what happens when the network drops halfway through submitting a form, or when they try to leave the page while a file upload is still in progress. Cover loading indicators, error states, retries and error boundaries.
- Prefer integration where it is practical. Mocking true external boundaries, such as a third-party payment gateway, is reasonable. Mocking your own internal helpers or your own data layer mostly hides the bugs that live between them. Let components and modules run together as much as the cost allows.
- Let production bugs grow the suite. When a defect escapes, write a test that reproduces it and fails before touching the fix, then change the code until it passes. Over time the suite accumulates the edge cases your system actually encounters rather than the ones someone imagined.
A related, well-established technique is mutation testing, which deliberately alters your code (flipping a condition, removing a line) and checks whether any test fails. Surviving mutations point directly at code that is executed but not verified, which is exactly the gap coverage cannot show. For component-level specifics, see these React testing anti-patterns that create false confidence.
Using coverage as a signal, not a goal
Coverage is not useless. A low number in a critical module is a genuine warning, and a report can reveal code paths that nobody tests at all. The problem starts when the percentage is the objective, because that rewards volume over rigor.
A lean suite at around 65% coverage that concentrates on risky workflows, complex business rules and failure recovery will prevent far more incidents than a brittle 95% suite built from happy paths and mocks. Before adding a test, a better question than "which lines are uncovered?" is "which realistic failure would this catch?"
Key takeaways
- Coverage shows which code ran, not whether its behavior was checked.
- Happy-path inputs, heavy internal mocking and assertion-free tests all raise the number without reducing risk.
- Target state transitions, error handling and integration between your own modules.
- Turn every escaped bug into a failing test first, then fix it.
- Track the failure modes your suite guards against, and treat the coverage figure as a supporting hint.