100% test coverage is a vanity metric. I see it all the time: teams chasing 100% coverage, writing tests that assert nothing meaningful, and feeling confident because the number is green. Then a bug ships to production that none of those tests caught. Coverage does not equal confidence, and treating it as the goal leads to tests that look thorough and protect you from nothing. This article breaks down why coverage misleads, what I check instead, and how to build a test suite that actually catches the bugs that matter.
Why coverage misleads
Coverage measures which lines of code were executed during the test run. It does not measure whether those tests actually verify correct behavior. A test that calls a function and asserts expect(result).toBeDefined() gives you coverage — and zero confidence. The line ran, the tool incremented a counter, and the report turned green, but nothing about the function's contract was checked. Multiply that across a codebase and you can hit 100% coverage while a meaningful regression walks straight through to production.
The deeper problem is that coverage is easy to game without anyone meaning to. When the number becomes the target, people write tests to move the number, not to catch bugs. The tests follow the path of least resistance: call the function, assert something trivially true, move on. The coverage report improves and the safety net gets thinner at the same time.
Risk-based testing is the alternative
Instead of asking "did we execute every line?", ask "did we test the things that would hurt the most if they broke?" That question forces you to think about business risk, not line counts. It also naturally produces a test suite that is uneven in the right way: deep where the cost of failure is high, lighter where it is low. Here is how I approach it in practice.
Identify the critical paths
Start by asking what the most expensive failure in your app would be. For an e-commerce site, it is the checkout flow — if customers cannot pay, revenue stops. For a SaaS, it is authentication and billing — if users cannot log in or get charged correctly, trust evaporates. For a healthcare app, it is data integrity — a corrupted record can have consequences far beyond a lost sale.
Once you know the critical paths, write thorough tests for them first. These should be integration tests that exercise the full flow, not just individual functions. A unit test that checks the price calculation in isolation will not catch the bug where the discount code is dropped between the cart and the payment processor. The integration test will.
Test the edges, not the happy path
Happy path tests are easy to write and catch almost nothing. They confirm that the code works when everything goes well, which is the scenario that rarely breaks in production. The bugs live at the boundaries: null inputs, concurrent operations, network failures, partial data, permission edge cases, time zones, leap years, and the thousand other things that deviate from the expected path.
Write tests that try to break things. Feed the function the inputs you would never expect a user to send. Simulate the database connection dropping mid-request. Submit the form twice in quick succession. The goal is not to be exhaustive about every possible input — that is impossible — but to probe the places where the code is most likely to be wrong.
The 70/20/10 ratio is a starting point, not a rule
A common guideline is 70% unit tests, 20% integration/API tests, and 10% UI/E2E tests. The reasoning is sound: unit tests are cheap and fast, so you want most of your coverage at that layer, while UI tests are expensive and flaky, so you want few of them. This minimizes cost and keeps the feedback loop tight.
But it is a starting point, not a rule. Adjust based on your risk profile. If your app is API-heavy with a thin frontend, you might be closer to 50/40/10. If your app is a complex single-page application with subtle state bugs, you may need more E2E than the ratio suggests. The point is to be deliberate about where you spend your testing budget, not to hit a number.
Flakiness is a disease
Google's research found that 1.65% of their tests were flaky overall, with UI and integration tools running much higher rates — WebDriver tests hit 10–18%, and Android emulators went up to 25%. You can read the full findings at https://testing.googleblog.com/2017/04/where-do-our-flaky-tests-come-from.html. The pattern is clear: the larger the test, the more likely it is to flake, because it depends on more moving parts and more shared state.
Flakiness is corrosive. Once a team stops trusting red builds and starts auto-retrying, the safety net turns into background noise. Engineers learn to ignore failures, and when a real regression lands, nobody notices until it reaches production. The only sustainable policy is: if a test is flaky, fix it or delete it. A flaky test is worse than no test, because it actively trains the team to ignore the signal.
AI-generated tests need human review
The 2026 CircleCI State of Software Delivery report shows that AI-driven code is breaking more often and taking longer to fix. You can download the report at https://circleci.com/landing-pages/assets/2026-state-of-software-delivery-report.pdf. AI can write test scaffolding fast — sometimes impressively fast — but it also generates what I call "slop tests": tests that look comprehensive, cover many lines, and assert on the wrong things. They give you coverage and confidence without giving you protection.
Human review of test logic is non-negotiable. When you accept an AI-generated test, read what it actually asserts. Does it check the business outcome, or does it check that the function returned something? Does it cover the edge case you care about, or does it cover the edge case the model guessed at? Treat the AI output as a draft, not a finished product.
What I actually check in a codebase
When I evaluate a test suite, I do not look at the coverage number first. I look at the shape of the testing. Specifically, I check whether the critical paths are covered by integration tests, not just unit tests — because the bugs that hurt live at the seams between modules, and unit tests do not see those seams.
I check whether tests assert on business outcomes, not just "it didn't throw." A test that passes by not throwing is a test that will pass when the code is wrong, which is the worst outcome a test can have.
I check whether flakiness is tracked and managed. Is there a known-flaky list? Are flaky tests quarantined or fixed promptly? Or does the team shrug and retry?
I check whether the most complex and risky functions are tested more thoroughly than CRUD utilities. A generated CRUD endpoint with a one-line controller does not need the same test investment as the billing calculation that touches three external services.
And I check whether CI fails fast on broken tests, or whether it takes 20 minutes to tell you something is wrong. A slow, noisy CI pipeline erodes the feedback loop that makes testing worthwhile in the first place.
Conclusion
Coverage is a side effect of good testing, not the goal. If you write tests for the right reasons — to catch the bugs that matter, to protect the critical paths, to probe the edges where failures hide — coverage takes care of itself. Chasing the number inverts that relationship and produces a suite that looks impressive and protects you from nothing. Measure coverage if you want, but measure it second, after you have asked whether your tests would catch the failure that would hurt the most.
Need help with this?
Get in touch — I take on a few new clients each month.
References
Need help with this?
I take on a few new clients each month. Let's talk about your project.
Get in touch