Coverage percentage is the most misleading number in software. A codebase at 90% can have every payment path untested, because payments are complicated and the utility functions are easy, and coverage rewards whichever you wrote more of.
The question worth asking is not how much is tested. It is whether the five things that would genuinely hurt if they broke are covered, and on most projects the honest answer is partially.
This is how I decide what to test on a project maintained by one person, where the time spent testing is time not spent building.
Why is coverage the wrong target?#
Because it measures lines executed, not consequences prevented, and those correlate weakly.
Chasing a number pushes effort toward whatever is easiest to cover — pure functions, formatters, mappers — because they take a minute each and move the percentage. The genuinely risky code involves a database, an external service and several states, so it costs an hour and moves the percentage barely at all.
The result is a suite that runs green while the checkout has never been exercised end to end. The number went up and the risk did not go down, which is worse than having no number because it produces confidence.
The alternative framing that works: for each flow, what does it cost if this breaks silently for a week? Order that list, test from the top, and stop when the cost of the next one is genuinely low.
What are the paths that actually matter?#
Five, on nearly every product, and they are the same five regardless of what the product does.
| Path | Cost if broken | Test type | Priority |
|---|---|---|---|
| Signup and login | Nobody can use it | End-to-end | Critical |
| Payment and subscription | Lost revenue, refunds | End-to-end plus unit | Critical |
| Permission checks | Data leaked between users | Integration | Critical |
| The core workflow | Product does not work | End-to-end | High |
| Pricing and calculations | Wrong invoices | Unit | High |
| Marketing pages | A typo | Smoke only | Low |
The bottom row is the point. Content pages account for a large share of most codebases and almost none of the risk, and testing them thoroughly is where a lot of testing effort quietly goes.
Permission checks deserve their place near the top despite rarely feeling urgent. A broken permission is not a bug the user reports — it is data shown to somebody who should not see it, and they may not tell you.
What should be a unit test?#
Anything where the logic is dense and the inputs are enumerable. These are cheap, fast and deterministic, and they are the tests worth writing by hand.
Pricing calculations, proration, date arithmetic, permission resolution, anything with branching business rules. The value is that the interesting cases — the boundary, the zero, the negative, the leap year — are exactly the ones a generated test does not think of.
// The cases worth writing are the ones somebody has to think about.
describe('proration', () => {
it('charges nothing when upgrading on the renewal date', …);
it('credits the unused portion when downgrading mid-cycle', …);
it('handles a month boundary where the next month is shorter', …);
it('rounds in the customer\'s favour, never against', …);
}); That last one is a business rule rather than a technical property, and it is the kind of thing that only exists in a test. Six months later nobody remembers the decision, and the test is the only record that it was deliberate.
What does not belong here: testing that a framework works, asserting implementation details, or snapshot tests of markup that change whenever anyone adjusts a class name. All three break constantly and catch nothing.
What should be an integration test?#
Anything crossing a boundary you own — a request through the handler to the database and back.
This is where permission bugs live, and unit tests structurally cannot find them. A permission function tested in isolation returns true or false correctly; the bug is the query in a different file that never called it.
These are the tests that catch a missing tenant filter, a transaction that does not roll back, or a cascade that deletes more than intended. They need a real database rather than a mock, because a mocked database agrees with whatever assumption you encoded.
Run them against a disposable instance — a container in CI, or a database branch where the platform supports it. The important property is isolation between tests, since shared state is the single largest cause of a suite that fails in a different order.
How many end-to-end tests should you have?#
Five. Not fifty, and the difference matters more than it sounds.
End-to-end tests are the slowest and flakiest thing in any suite. They start a browser, wait on network, and depend on timing. That cost is worth paying for the flows where nothing else gives confidence, and it is a bad trade everywhere else.
Five keeps the set small enough that a failure is always worth investigating. Fifty produces a suite where two are failing at any moment, everyone knows which two, and a genuine failure hides among them.
- Sign up, verify, log in — the door into the product.
- The core workflow — whatever the product is actually for, once, happy path.
- Subscribe and pay, against the provider in test mode.
- A permission boundary — attempt to reach another account's data and assert failure.
- One critical integration, if the product depends on a third party.
Those five cover the paths where a silent regression costs real money. Everything else the browser could exercise is covered more cheaply and more reliably a layer down.
Notice that four of the five involve money or access rather than functionality. That is deliberate. A feature that renders wrong produces a support ticket; a permission that leaks produces an incident, and a payment path that silently fails produces revenue you never knew you lost. The asymmetry in consequences is what justifies spending the expensive test budget there rather than on the parts of the product that get used most.
It is also worth running these against something resembling production rather than a fresh empty database. A checkout test with one product and no history passes on arrangements that break with real catalogue sizes, and the whole reason to accept the cost of end-to-end tests is that they exercise the system as it actually is.
How do you keep the suite from becoming flaky?#
Three causes account for almost all of it, and each has a standard fix.
Time#
Tests using the real clock fail at midnight, at month boundaries and in a different timezone on a colleague's machine. Inject the clock so a test can state what time it is, and any date-dependent behaviour becomes deterministic.
Shared state#
Tests that pass individually and fail together are sharing a database, a cache or a module-level variable. Each test should create what it needs and clean up, or run in a transaction rolled back afterwards.
Fixed waits#
An end-to-end test sleeping two seconds passes on a fast machine and fails on a loaded CI runner. Wait for a condition — an element appearing, a request completing — never for a duration.
A test that cannot be stabilised in an hour should be deleted or moved out of the blocking set. An honest suite of twelve tests is more useful than forty where four fail at random, because the second teaches everyone to re-run rather than investigate, and then a real failure gets re-run too.
When should you write them?#
The critical five before launch. Everything else in response to something breaking.
Test-first as a universal practice does not survive contact with an MVP timeline, and pretending otherwise produces either a slower build or an abandoned discipline. What does survive is testing the five paths where a regression is expensive, and writing a test for every bug that reaches a user.
That second habit is the one that compounds. A bug that reached production is proof that a path was both reachable and untested, which makes it exactly the test worth having. Over a year the suite converges on what actually breaks in this specific product, which is far better targeting than any coverage rule produces.
The regression test also settles the question of whether the fix worked, which is otherwise a matter of trying it once and hoping. Writing it before the fix is better still, because a test that fails for the right reason and then passes has demonstrated something, whereas a test written afterwards has only demonstrated that it agrees with the code you just wrote.
There is one category worth testing before it breaks rather than after: anything you are about to change substantially. A refactor of a payment flow is considerably safer with tests written the day before, and those tests are easy to write while the existing behaviour is still the correct behaviour. Writing them afterwards means encoding whatever the new code does, which proves nothing about whether it still does the old thing.
How do you write a test that stays useful?#
Most tests that get deleted were testing how something works rather than what it does. Four habits keep them alive through a refactor.
Assert on behaviour, not on calls#
A test asserting that a function called the repository twice breaks the moment somebody batches the queries, even though nothing observable changed. Assert on the outcome — the record exists, the total is right — and the test survives any implementation that produces it.
Set up through the public path#
Creating test data by inserting directly into tables is fast and couples the test to the schema. Creating it through the same service the application uses means the setup itself exercises real code, and a schema change breaks one helper rather than forty tests.
Name the scenario, not the function#
"charges nothing when upgrading on the renewal date" tells a future reader what rule is being protected. "test upgradeSubscription" tells them nothing, and when it fails they cannot tell whether the behaviour or the expectation is wrong.
One reason to fail#
A test asserting six things fails on the first and hides the other five. Several focused tests take marginally longer to write and tell you considerably more when something breaks, because the pattern of which ones failed is itself information.
The underlying principle is that a test is read far more often than written, usually by somebody who did not write it and is trying to work out whether their change is wrong or the test is stale. Everything above optimises for that moment.
What do you do when the suite gets slow?#
Fix it, because a slow suite stops being run and an unrun suite is worse than none — it produces confidence without providing any.
Find the slow tests before optimising anything#
Most runners report per-test timing. It is almost always a handful of tests taking most of the time, usually the ones starting a browser or seeding a large fixture. Optimising the fast majority is effort spent where there is nothing to gain.
Share expensive setup, carefully#
Starting a database container per test file is slow; starting one for the whole run and isolating each test in a transaction is fast and still isolated. That single change frequently halves an integration suite.
Split the fast set from the slow set#
Unit tests should run in seconds and be runnable on save. Integration and end-to-end tests belong in a separate command that runs before a push. Conflating them means the fast feedback loop is as slow as the slowest thing in it.
Parallelise last#
It is the obvious answer and it hides problems rather than solving them, because parallel execution surfaces every shared-state bug at once. Worth doing after isolation is genuinely correct, and painful before.
What about generated tests?#
Useful for the obvious paths, and they consistently miss the ones that matter.
A model writing tests for a function produces the happy path, a null check, and an empty array. Those are worth having and they take seconds. What it does not produce is the case where the discount is larger than the subtotal, or where a customer downgrades on the last day of a month with thirty-one days.
This is the same division that applies to generated code: the conventional parts are generated and the parts requiring judgement about the domain are not. Tests for signup, permissions and anything touching money get written by hand, because the interesting cases are the point.
The failure worth avoiding is a large generated suite that raises coverage and tests nothing anyone cared about, while creating maintenance cost every time an implementation detail changes.
What does this cost on a real project?#
Roughly a day for the critical five, plus ongoing time proportional to how often things break.
On a standard web app: the unit tests for pricing and permissions are perhaps three hours, the integration tests around the permission boundary two hours, and the five end-to-end tests three hours including the fixture setup. Call it a day, spread across the build rather than done at the end.
The ongoing cost is a test per production bug, which is fifteen minutes each and self-limiting — a codebase that keeps producing bugs in one area gets tests there quickly, and one that does not stays cheap.
Coverage measures how much of the code ran. The useful question is whether the five things that would cost you money are covered, and that number is usually available by counting on one hand.
What runs where?#
Fast and deterministic on every push; slow and expensive on a schedule.
Unit and integration tests run in CI on every push alongside the type-check, because they are quick enough to be part of the edit loop. End-to-end tests against a deployed preview run on merge to main, since they are slower and need somewhere deployed to point at.
Anything touching a paid third-party service belongs on a schedule rather than on every commit, both for the cost of the calls and because your own pipeline should not start failing when somebody else has an outage.
The gating rule matters more than the split: whatever runs on a pull request must block merging. A test suite that reports without preventing is one everyone learns to ignore, which is the same failure mode as an alert nobody acts on.
Conclusion#
Stop measuring coverage and start listing the flows where a silent regression costs money. On nearly every product that list is signup, payments, permissions, the core workflow and the calculations — five things, and they can be covered in about a day.
Unit-test the dense logic by hand, because the interesting cases are the ones that require thinking about the domain. Integration-test anything crossing into the database, since that is where permission bugs live. Keep end-to-end tests to five, and treat any flaky test as broken.
Then add a test for every bug that reaches a user. Over a year that produces a suite targeted at what actually breaks in your product, which is a better outcome than any percentage — and it costs a fraction of what chasing one does.
If you inherit a codebase with no tests at all, resist the instinct to start at the top of a file and work down. Write the five critical-path tests first, in a day, and get them running in CI. That gives you a safety net for everything you are about to change, which is the actual reason to have tests on a codebase you do not yet understand. Broad coverage of code you have not touched protects nothing you are working on.