A Testing Strategy for Real Teams: Confidence Over Coverage#
I have worked on codebases with 95% test coverage that broke in production every week, and on codebases with 60% coverage that almost never did. Coverage measures which lines ran during tests. It says nothing about whether the tests would catch a real bug.
A testing strategy should optimize for one thing: confidence that the software works for users, at a reasonable cost.
The testing trophy#
The classic test pyramid says: write many unit tests, fewer integration tests, very few end-to-end tests. For modern web applications, I find Kent C. Dodds' "testing trophy" a better guide:
- Static analysis at the base: TypeScript, ESLint. It catches typos and type errors for almost free.
- Unit tests for pure logic: calculations, parsers, validation rules.
- Integration tests as the largest layer: several units working together with real or realistic dependencies.
- End-to-end tests at the top: a few critical user journeys through the whole system.
Integration tests get the most weight because they give the most confidence per test. Most bugs live in the seams between units, not inside them.
Test behavior, not implementation#
The single biggest improvement in a test suite comes from testing what the code does, not how it does it.
1// Brittle: breaks if we rename an internal method, catches nothing2expect(service["calculateFee"]).toHaveBeenCalledWith(1000);3 4// Useful: describes behavior the business cares about5it("charges a 2.5% fee on payouts over $100", async () => {6 const payout = await payouts.create({ affiliateId, amountCents: 20_000 });7 expect(payout.feeCents).toBe(500);8});
A good test survives refactoring. If you restructure the code without changing behavior and twenty tests fail, those tests were a tax, not a safety net.
Integration tests with a real database#
Mocking the database is tempting because it is fast. It is also where many tests stop being useful: a mocked repository happily returns data that a real query with a real constraint would reject.
For backend services, I run integration tests against a real PostgreSQL instance in a container:
1describe("POST /payouts", () => {2 beforeEach(async () => {3 await db.query("TRUNCATE payouts, wallets RESTART IDENTITY CASCADE");4 await seedWallet({ affiliateId, balanceCents: 10_000 });5 });6 7 it("rejects a payout larger than the balance", async () => {8 const res = await request(app).post("/payouts").set(auth(affiliate)).send({ amountCents: 20_000 });9 expect(res.status).toBe(400);10 expect(await walletBalance(affiliateId)).toBe(10_000);11 });12});
This tests routing, validation, authorization, business logic, the SQL and the constraints in one go. It is slower than a unit test, but it catches far more.
Mock only what you do not own and cannot run locally: payment providers, email services, third-party APIs. Mock them at the HTTP boundary, so your own client code is still exercised.
End-to-end tests: few and critical#
End-to-end tests drive a real browser through the real application. They give the highest confidence and cost the most: they are slow and can be flaky. Keep them for journeys where a failure would be a business emergency:
- Sign up and log in.
- Checkout or payment.
- The core action of your product.
Five reliable end-to-end tests are worth more than fifty flaky ones that everyone has learned to re-run.
Fight flakiness relentlessly#
A flaky test is worse than no test, because it teaches the team to ignore failures. Common causes and fixes:
- Timing: wait for specific conditions instead of fixed sleeps.
- Shared state: isolate test data so tests do not depend on each other or on run order.
- Real time and randomness: inject clocks and seeds.
- External services: stub them in tests that are not specifically about them.
Quarantine a flaky test immediately, fix it within days, and track the flaky-test rate as a team metric.
What to test first in a legacy codebase#
When a codebase has few tests, do not aim for coverage. Write integration tests around the most valuable and most frequently changed flows, and add a regression test for every bug you fix. Within months, the areas that matter most are covered — exactly where you need confidence.
Conclusion#
Lean on static analysis, unit-test pure logic, invest most heavily in integration tests against real dependencies, and reserve end-to-end tests for critical journeys. Test behavior rather than implementation, and treat flakiness as a bug. Measure your strategy by how often production surprises you, not by a coverage percentage.