A codebase I inherited had 94% test coverage and broke in production roughly once a fortnight. The tests were thorough, fast, and green. They also tested almost nothing that could actually fail.
Every service method had a test where the repository was mocked. The test asserted that the service called the mock with the right arguments. It passed whether or not the query was valid, whether or not the column existed, whether or not the transaction committed. The mock agreed with whatever the code did, which meant the tests were a mirror rather than a check.
That is the trap in testing advice: coverage measures how much code ran, not how much behaviour is verified.
The Three Kinds, Described by What They Catch
Forget the pyramid diagram for a second and think about failure modes.
Unit tests catch logic errors in a single piece of code. Milliseconds to run, trivial to debug — a failure tells you the exact function. They are blind to anything involving integration: wrong SQL, wrong serialisation, wrong assumption about a third party.
Integration tests catch the seams. Your code plus a real database, or your code plus a real HTTP layer. Slower, harder to debug, and they find the bugs that actually reach production. This is the layer most teams have too few of.
End-to-end tests catch "the whole thing is wired up wrong." A real browser, a real server, a real database. Slow, flaky, expensive to maintain — and the only thing that catches a broken deploy config or a frontend calling the wrong endpoint.
The pyramid says lots of unit, some integration, few E2E. I think that is roughly right for the count and misleading about the value. My honest split of where bugs get caught in the systems I have worked on is closer to: unit tests catch a third, integration tests catch over half, and E2E catches the rest — but the E2E failures tend to be the embarrassing ones.
Test at the Boundary You Actually Have
The most useful change I made to how my teams test: for backend work, the default test is an HTTP-level test against a real database.
// Not a mock in sight. This would have caught the invalid SQL.
describe("POST /orders", () => {
it("rejects an order with no items", async () => {
const res = await request(app)
.post("/orders")
.set("Authorization", `Bearer ${token}`)
.send({ items: [] });
expect(res.status).toBe(400);
expect(res.body.error.code).toBe("validation_failed");
});
it("persists the order and returns it", async () => {
const res = await request(app)
.post("/orders")
.set("Authorization", `Bearer ${token}`)
.send({ items: [{ sku: "ABC", qty: 2 }] });
expect(res.status).toBe(201);
// Verify the state, not the call. This is the part mocks cannot do.
const row = await db.order.findUnique({ where: { id: res.body.id } });
expect(row?.itemCount).toBe(2);
});
});
One test here covers routing, validation, authorisation, the service logic, the SQL, the transaction and the response shape. It runs in maybe 40ms against a containerised Postgres. It is worth more than a dozen mocked unit tests, and it does not break when you refactor the internals — which is the other quiet cost of heavy mocking.
Keep real unit tests for genuine logic: pricing rules, date arithmetic, permission calculations, state machines. Pure functions with interesting edge cases are exactly what unit tests are good at.
Mock the Network, Not Your Own Code
The rule that resolves most arguments about this:
Mock things you do not own and cannot run. Stripe, an email provider, a partner API. You do not want your test suite depending on someone else's uptime, and you cannot make their sandbox return a timeout on demand.
Do not mock things you own. Your repository, your service, your own modules. Use the real ones. If that is painful, the pain is telling you something about the design.
Use a real database in a container. This is the single biggest upgrade available to most test suites. An in-memory substitute has different SQL semantics, different constraint behaviour and different transaction handling — so it agrees with your code while production disagrees.
For third parties, intercept at the HTTP layer rather than stubbing your own wrapper. That way you are still exercising your serialisation, your error handling and your retry logic.
What Makes Tests Flaky
Flaky tests are worse than no tests, because they train the team to re-run the suite instead of reading the failure. The causes are consistent:
Shared state between tests. Test A creates a user, test B counts users, and the order changed. Each test gets its own data, ideally in a transaction rolled back afterwards.
Time. A test that passes except at midnight, or on the last day of a month. Freeze the clock.
Arbitrary waits. sleep(500) in an E2E test passes on your laptop and fails in CI. Wait for a condition — an element, a status, a row — never a duration.
Test order dependence. If the suite fails when run in a different order, the tests are lying to you. Randomise the order deliberately to find these.
Real network calls. One un-mocked third-party call and your suite fails when their sandbox has a bad afternoon.
My rule now: a test that fails intermittently gets fixed or deleted within a week. Leaving it is how a suite loses its authority.
Where E2E Earns Its Cost
E2E tests are expensive to write and maintain, so be ruthless about scope. I keep somewhere between five and fifteen, covering only the flows where a failure is unacceptable.
For a typical product that means: sign up, log in, the core action the product exists for, and checkout or payment. Not every form, not every settings page, not error states you can cover more cheaply.
Run them after merge rather than on every pull request, unless they finish in a couple of minutes. A PR pipeline that takes twenty minutes because of browser tests is a pipeline people stop waiting for.
What I Would Actually Measure
Coverage is a weak signal — 94% proved that. Two better ones:
Escaped defects. How many bugs reached production, and for each, ask which test would have caught it. That question turns incidents into a concrete test-writing backlog, and it points at real gaps rather than uncovered getters.
Suite duration. Under ten minutes on a pull request or people stop waiting for it. Slow tests get skipped, and skipped tests are worth nothing.
And write the test when you fix the bug, every time. That habit alone builds a suite shaped like your actual failure modes rather than your code structure — which is the difference between 94% coverage and confidence.



