A Test Strategy Chosen by What Each Layer Can Prove
Business logic to unit tests, database behaviour to integration tests, contracts to contract tests, and only the critical flows to end-to-end.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
Which kind of test should cover which part of a backend, and what does each kind actually prove?
We need to change the pricing engine and the order schema without breaking checkout. The suite must catch that before production, run fast enough that people run it, and not break on every unrelated refactor.
Aim for high coverage with unit tests everywhere, mocking every dependency. Fast, isolated, deterministic — the textbook answer.
Mocked-database tests prove your code calls the mock as written. They cannot catch a missing index, a constraint violation, a transaction that was never committed or a query whose SQL is malformed (Test Against the Real Database).
- Mocked-database tests prove your code calls the mock as written. They cannot catch a missing index, a constraint violation, a transaction that was never committed or a query whose SQL is malformed (Test Against the Real Database).
- Mocks encode your belief about the dependency. When the belief is wrong, the tests pass and production fails — and the test suite actively increases confidence in the wrong thing.
- Heavy mocking couples tests to structure. Every refactor breaks dozens of tests that were asserting call sequences rather than behaviour, so the suite becomes a tax and people start deleting tests to ship.
- Coverage percentage measures lines executed, not behaviours verified. A suite at 90% coverage can assert almost nothing if the assertions are shallow.
- The opposite failure is just as common: a suite that is all end-to-end, so it takes forty minutes, fails intermittently, and nobody trusts a red build.
What is actually happening
- Each test type has a boundary — what is real and what is substituted — and that boundary determines exactly what the test can prove and what it cannot.
- Unit tests substitute everything outside the unit. They prove logic: pricing rules, state transitions, validation, edge cases. Fast, numerous, and blind to anything involving I/O.
- Integration tests keep a real dependency, usually the database. They prove that the code and that dependency agree: SQL is valid, constraints fire, transactions commit and roll back, migrations apply (Database Constraints).
- Contract tests prove that a producer and a consumer agree on a wire format without running both at once. They catch the breaking change that unit tests on either side cannot see (Contract Tests Between Services).
- End-to-end tests run the real system through its real interfaces. They prove a whole flow works, and they are slow, flaky and expensive — so they are for the few flows whose failure is unacceptable.
- The layers are complements, not a hierarchy of quality. A test type used outside its boundary is where suites go wrong: a unit test asserting database behaviour proves nothing; an end-to-end test asserting a pricing edge case is a slow, flaky way to test a pure function.
- The right question for any behaviour is not "what level should this be" but "what is the fastest test that could actually fail if this were wrong?" That single question resolves most placement arguments.
- Test doubles are not interchangeable: a stub returns canned values, a mock asserts interactions, a fake is a working lightweight implementation. Mocks couple to structure most tightly and are the usual cause of brittle suites.
Each layer, its test, and what that test cannot see
The last column is the one that matters. Every test type has a blind spot that follows directly from what it substituted, and most production escapes are something that fell into one of those blind spots while a green suite said otherwise.
Read it as an allocation problem: put each behaviour where the fastest test that could genuinely fail on it lives.
| Layer | Test type | What is real | Proves | Blind to |
|---|---|---|---|---|
| Business logic, pricing, state machines | Unit | Only the code under test | Rules, edge cases, invariants — thousands of cases cheaply | Anything involving I/O, SQL, transactions or the wire |
| Repositories, queries, transactions | Integration, real engine | The database | Valid SQL, constraints, isolation, rollback, migrations (Transactions from Application Code) | Whether callers use it correctly; cross-service contracts |
| HTTP handlers, middleware, serialization | Integration, in-process | Router, middleware, serializer, usually the DB | Status codes, error categories, authz enforcement, payload shape | Real network, proxies, timeouts, TLS |
| Producer/consumer API boundary | Contract | The agreed schema; each side alone | Neither side broke the format the other depends on | Whether the behaviour behind the format is correct (Contract Tests Between Services) |
| Critical user flows | End-to-end | Everything | The flow actually works through real interfaces | Edge cases — too slow and too flaky to enumerate them |
| Latency and capacity | Performance | A realistic system under load | Behaviour under concurrency and saturation (Performance Testing a Backend) | Functional correctness |
Mocking the thing whose semantics you depend on
The rule that resolves most mocking arguments: mock what is slow, costly or non-deterministic; keep what encodes semantics your code relies on. A payment provider is a reasonable mock — you do not want real charges. A database is not, because the semantics are the thing being tested.
The example below is the canonical case. Both tests are green. One of them proves that a duplicate order is rejected; the other proves that a mock was configured to return one row.
it('rejects duplicate order refs', async () => {
const repo = { findByRef: jest.fn().mockResolvedValue({ id: 'existing' }),
insert: jest.fn() }
await expect(createOrder(repo, { ref: 'A1' })).rejects.toThrow(ConflictError)
expect(repo.insert).not.toHaveBeenCalled()
})
// Green. Proves the code branches when findByRef returns something.
// Does NOT prove:
// - that a unique index on (tenant_id, ref) exists
// - that two CONCURRENT requests cannot both pass the check
// - that the driver error is translated to ConflictError
// - that the transaction rolls back the partial writeit('rejects duplicate order refs, including concurrently', async () => {
await createOrder(db, { tenantId: 't1', ref: 'A1' })
// Sequential duplicate: the constraint, not the read, must reject it.
await expect(createOrder(db, { tenantId: 't1', ref: 'A1' }))
.rejects.toMatchObject({ category: 'conflict', code: 'duplicate_order_ref' })
// Concurrent duplicate: exactly one wins. A check-then-insert passes the
// test above and fails this one. [[backend-races]]
const results = await Promise.allSettled([
createOrder(db, { tenantId: 't1', ref: 'B2' }),
createOrder(db, { tenantId: 't1', ref: 'B2' }),
])
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1)
// And the constraint is scoped per tenant, not global.
await expect(createOrder(db, { tenantId: 't2', ref: 'A1' })).resolves.toBeDefined()
})The second test fails if the unique index is missing, if it is scoped wrongly, if the driver error is not translated, or if the code uses a check-then-insert that two concurrent requests can both pass. None of those failures are visible to the first test, and all of them are things that actually break in production.
Placing a behaviour: the fastest test that could fail
Arguments about test placement usually stall on taxonomy — "is this a unit test?" — when the productive question is empirical. If this behaviour were wrong, which is the cheapest test that would definitely turn red?
Applied consistently, this produces a suite whose shape follows the service rather than a diagram. A service that is mostly SQL ends up integration-heavy, and that is correct rather than a failure to reach the pyramid.
If this were wrong, what is the fastest test that would actually fail?
when The behaviour is a pure function of its inputs: a pricing rule, a state transition, a validation predicate, a retry-delay calculation.
cost Nearly nothing. Write many, cover the edges exhaustively.
when Correctness depends on the engine: constraints, transactions, isolation, migrations, query results, concurrent writes.
cost Seconds per test and real infrastructure in CI (Test Against the Real Database).
when The behaviour is the HTTP surface: status codes, error categories, authorization enforcement, serialization shape.
cost Boots the app per suite; still far cheaper than end-to-end.
when A consumer depends on a format you produce, or you depend on one you consume.
cost A shared artifact and a verification step in both pipelines (Contract Tests Between Services).
when A flow whose failure is unacceptable and whose parts cannot be verified separately — signup, checkout, payment.
cost Minutes, flakiness, and maintenance. Keep the set to a handful.
when The behaviour depends on real traffic, real data volume or real third-party behaviour.
cost You find out in production — so make sure you find out quickly (The Metrics a Backend Must Emit).
How to build it
Most important first.
- Map each layer to its test type deliberately: pure business logic to unit; anything touching SQL to integration against a real engine; every API boundary to contract or integration; three to five critical flows to end-to-end.
- Design for testability by keeping business logic free of I/O. A pricing function taking data and returning a decision needs no mocks at all (The Service Layer).
- Prefer fakes over mocks where a fake is cheap — an in-memory repository that genuinely implements the interface breaks far less often than a mock asserting call order.
- Test behaviour, not implementation. Assert the order was created and the payment recorded, not that
save()was called twice. - Test the error paths deliberately, especially the ones the error taxonomy defines. Every category should have a test that produces it (An Error Taxonomy That Maps Cause to Response).
- Make the fast tests genuinely fast and run them on every save; run the slower layers on every push and the slowest on merge. Suite speed determines whether tests get run at all.
- Treat flakiness as a bug with an owner. One tolerated flaky test teaches the team that red builds are noise, and that lesson is expensive to unlearn.
- Use coverage to find untested areas, never as a target. Coverage as a goal produces assertion-free tests that execute lines.
What can go wrong
- Mock drift: the real dependency changes, the mock does not, and the suite stays green while production breaks. This is the defining failure of mock-heavy suites.
- Tests coupled to structure, so a refactor that changes no behaviour turns the suite red and the team learns to distrust it.
- Shared mutable state between tests — a database not reset, a module-level cache, a fixed clock left set — producing order-dependent results that pass locally and fail in CI.
- End-to-end tests as the primary safety net: slow, flaky, and failing for reasons unrelated to the change, so failures get re-run rather than read.
- Testing only the happy path, so every error branch — the majority of production behaviour during an incident — is unexercised.
- The mitigation failing: an integration test against a substitute engine with different semantics, which passes and proves nothing about the real database (Test Against the Real Database).
- A suite so slow that people push without running it, which converts every test into a post-hoc report rather than a gate.
- Concurrency bugs are the class tests catch least reliably: a race that needs a specific interleaving passes a thousand runs and fails in production. Test the *mechanism* — the version check, the unique constraint, the lock — rather than trying to reproduce the interleaving (Optimistic Concurrency).
- Parallel tests sharing a database race on fixtures. Isolate per test with a transaction that rolls back, or per worker with a separate schema (Reasoning About Races: A Method, Not an Instinct).
- Authorization must be tested explicitly, per endpoint and per object. "Can user A read user B's order" is a test, and its absence is the most common serious backend vulnerability (Object-Level Authorization).
- Test that tenant isolation holds under every query path. A missing tenant predicate is invisible in a single-tenant test fixture (Tenant Isolation).
- Never point tests at production, and never seed test databases with production data unless it is genuinely anonymised — test environments have weaker access controls by design (Sensitive Data Classification).
- Keep test credentials out of the repository like any other secret; a test key in git is a real key in git (Secrets Are Not Configuration).
- Add regression tests for every security fix. A vulnerability that recurs is a vulnerability nobody wrote a test for (Security Regression Testing).
- "High coverage means well tested." Coverage measures execution, not assertion. A suite can execute every line and verify almost nothing.
- "Unit tests are the foundation, so most tests should be unit tests." Most tests should be at the layer that can actually fail when the behaviour is wrong. For a data-heavy service that is often integration.
- "Mock everything for isolation." Isolation from a dependency also means isolation from its truth. Mock what is slow, expensive or non-deterministic; keep what encodes semantics you rely on.
- "End-to-end tests give the most confidence." They give the broadest confidence per test and the least per minute, and their flakiness erodes the confidence they provide.
- "The test pyramid is a rule." It is a heuristic from a particular kind of system. A thin service that is mostly SQL has a legitimately different shape.
- "If it is hard to test, write an integration test." Hard-to-test usually means logic is tangled with I/O, and the integration test hides the design problem rather than solving it.
Operating it
- Track suite duration per layer over time. A slow creep is what eventually stops people running tests locally.
- Track flaky-test rate and quarantine flakes explicitly rather than by re-running. An unmeasured flake rate always grows.
- When a production bug escapes, ask which test layer should have caught it. That answer, collected over time, is a far better guide to where tests are missing than coverage is.
- Watch for tests that have never failed. A test that cannot fail is documentation at best; mutation testing is the tool that answers this properly.
- At 10x test count, parallelism and isolation become the constraint. Tests sharing a database need per-test transactions or per-worker schemas (Test Against the Real Database).
- At many services, end-to-end tests across services stop being viable — you cannot run everyone's stack. Contract tests are what replace them (Contract Tests Between Services).
- A larger team makes the fast/slow split matter more: the pre-merge suite must fit inside the time a person will wait, or the discipline erodes team-wide.
- Nothing about the layer-to-test mapping changes with scale. What changes is which layers are affordable to run on every commit.
- Integration tests are slower and need real infrastructure in CI. They also catch the failures unit tests structurally cannot, so the cost buys something specific.
- Fewer mocks means less isolation: a test failure may point at the dependency rather than at your code, which takes longer to diagnose but is usually a real problem.
- Comprehensive end-to-end coverage is genuinely reassuring and genuinely unaffordable. Keeping the set small is a deliberate acceptance of risk.
- Designing for testability shapes the code — dependency injection, I/O at the edges — which is generally good structure and is still a constraint you are choosing (Dependency Management Without the Container).
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe boundary-determines-proof principle holds for any language and any framework.
- SCALE-SPECIFICA single service can afford end-to-end tests against its whole stack; across many services that becomes impossible to run and impossible to keep stable, which is precisely the situation contract tests exist for.
- FRAMEWORK-SPECIFICFrameworks differ in what they make cheap: Django and Rails ship transactional test cases and fixture loading against a real database, so integration testing is the default path; a bare Node service must assemble containers, migrations and per-test isolation itself, which is why mock-heavy suites are far more common there. The right strategy is the same; the friction is not.
- SIMPLIFIEDMutation testing, property-based testing, fuzzing and chaos experiments are real tools deliberately left out here. Their depth belongs to a Testing & Reliability Engineering domain that does not exist yet.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — property-based testing, mutation testing, fuzzing, chaos engineering and the discipline of measuring a suite's actual defect-detection power.