What to Test When There Is No Time
The pyramid is advice about cost, not virtue. Test the boundary you actually promise, and accept that some code is not worth testing at all.
Testing advice tends to arrive as morality — more tests are better, high coverage is professional, untested code is reckless. That framing is why teams under pressure either write tests they do not believe in or skip them and feel bad. Neither produces working software.
The useful framing is economic. A test costs time to write and time to maintain forever. It pays out when it catches a defect you would otherwise have shipped. Some tests have excellent returns, some have terrible ones, and the difference is predictable.
The pyramid is about cost
The pyramid — many unit tests, fewer integration, fewest end-to-end — is usually explained as though the shape were inherently correct. It is just a statement about cost: unit tests are fast and cheap to run, so you can afford many; end-to-end tests are slow and fragile, so you can afford few.
The part that gets lost is the other axis: confidence per test. A passing unit test tells you one function behaves as its author expected. A passing end-to-end test tells you a user can actually do the thing. The second is worth far more, which is why the correct shape is not maximally bottom-heavy — it is whatever gives you the most confidence per hour spent, and that usually means more integration tests than the pyramid suggests.
What heavy mocking actually costs
Consider a service test where the repository, the mailer, the payment client and the clock are all mocked. It runs in two milliseconds, has 100% coverage of the service, and verifies almost nothing.
What it asserts is that the code makes the calls the test author expected it to make, in the order they expected. So:
- It fails when the code is refactored even though behaviour is unchanged, because the calls moved. The test that was supposed to enable refactoring now taxes it.
- It passes when the code is broken, if the breakage is in the interaction with the real dependency — a wrong column name, a query that returns nothing, an API whose response shape changed. The mock returns what the author imagined, so the test agrees with the imagination rather than reality.
A mock is a hypothesis about how a dependency behaves, and a test built entirely from hypotheses verifies only that your hypotheses are self-consistent.
Mock at genuine boundaries — a third-party API, an email provider, the clock, anything with a real-world side effect or a cost. Do not mock your own database. Which leads to the type of test that is undervalued.
Integration tests against a real database
The highest-return test for a typical backend is one that exercises a route or a service against a real database. It catches the errors that actually happen — wrong queries, missing indexes causing timeouts, constraint violations, transaction and rollback behaviour, serialisation differences — none of which a mocked test can see.
The objection is speed, and it is largely solved:
// each test runs in a transaction that is never committed
beforeEach(async () => { tx = await db.begin(); });
afterEach(async () => { await tx.rollback(); });Rolling back is far faster than truncating and reseeding, and it gives perfect isolation — tests cannot see each other's data and can run in any order. With a containerised database this is a few hundred milliseconds of setup and single-digit milliseconds per test.
The trade to know about: a test inside a transaction cannot easily test transactional behaviour itself, and it will not surface concurrency effects. For those, use a small number of tests with real commits against a dedicated schema.
What is worth testing, concretely
Ranked by return, highest first:
- Pure logic with rules and edge cases. Pricing, discounts, splitting amounts, permissions, date arithmetic, state transitions. Cheap to test, easy to get subtly wrong, expensive when wrong. If you write only one kind of test, write these.
- The critical user path, end to end. Sign up, check out, publish. One test that proves the money path works is worth a hundred asserting getters.
- Anything involving money, permissions or data deletion. Where the cost of being wrong is unrecoverable, test the boundaries deliberately.
- Bugs you have already had. See below — this is the best return available.
- Contracts other people depend on. API response shapes, published events, database migrations.
What is usually not worth it:
- Code with no branches — a handler that maps a request to one call and returns it. A test asserts that the code is the code.
- Framework behaviour. The router works; it is tested by its authors.
- Third-party libraries. You are testing your mock of them, which is circular.
- Presentational components with no logic. Snapshot tests over markup mostly generate diffs to approve without reading, which trains exactly the wrong habit.
Coverage is a diagnostic, never a target
Coverage measures which lines executed. It does not measure whether anything was asserted, so a test that calls a function and asserts nothing produces the same number as one that checks every edge case.
As a diagnostic it is useful: a coverage report showing an untested payment module is real information. As a target it changes behaviour for the worse — a team required to hit 80% will hit 80%, and the cheapest route there is tests over trivial code, which is precisely the code least worth testing. You end up with a slower suite, a larger maintenance burden, and no more confidence.
The more useful signal is coverage of the diff. New logic arriving with no tests is worth a comment in review; the repository-wide percentage is not worth a gate.
The regression test is the best deal available
When a bug is found in production, you already have everything a good test needs: a confirmed defect, exact reproduction steps, and evidence that this area is where mistakes happen.
The discipline is to write the failing test before the fix. Watch it fail — which proves it actually exercises the bug — then fix the code and watch it pass. A test written after the fix is a test you have never seen fail, and a surprising number of those do not fail even when the bug is reintroduced.
This has the best return of any test you will write, because it is targeted by evidence rather than by guessing. A team with no formal testing policy that does only this will, within a year, have a suite concentrated exactly where their software is fragile.
The short version
Judge tests by return rather than by quantity. Mock real boundaries and not your own database — heavily mocked tests fail on refactors and pass on real breakage. Integration tests inside a rolled-back transaction give the most confidence per second of runtime. Concentrate on branching logic, the critical path, and anything involving money or permissions. Read coverage, do not target it. And every production bug should leave a test behind that you watched fail first.