The testing pyramid says: many unit tests, fewer integration tests, very few end-to-end tests. As a guide to cost it is sound. As a specification it produces suites with thousands of fast tests and a production incident every month, because the failures were never in the units.
A better starting point is to enumerate how your system actually breaks, then choose the cheapest test that would have caught each class. Do that honestly and the distribution that falls out is usually heavier in the middle than the pyramid suggests.
Where real failures come from
In the systems I have worked on, the recurring categories are: a shape mismatch between two services, a query behaving differently against real data volumes, a permission check missing on one endpoint, a migration that did not account for existing rows, and a concurrency condition. Exactly one of those — occasionally the concurrency one — is reachable by a unit test of a single class.
That is not an argument against unit tests. They are fast, they localise failure precisely, and they are the right tool for logic with branches: pricing rules, state machines, parsers, permission resolution. It is an argument against assuming coverage of units implies confidence in a system.
Contract tests for the boundaries you do not control
Between services, the highest-value test is a contract test: the consumer declares what it needs from the provider, and the provider verifies it can satisfy every consumer's expectations in its own pipeline. It catches the exact failure that end-to-end tests are usually deployed to catch, without requiring both systems to be running at once.
# Consumer side: state what we depend on. Not the whole response — the parts
# we actually read. Over-specifying makes the provider unable to evolve.
expectation = {
'given': 'an invoice exists with id inv_1',
'request': {'method': 'GET', 'path': '/invoices/inv_1'},
'response': {
'status': 200,
'body': {
'id': matcher.string('inv_1'),
'total_minor': matcher.integer(12000),
'currency': matcher.regex(r'[A-Z]{3}', 'GBP'),
'status': matcher.one_of('draft', 'issued', 'paid', 'void'),
},
},
}
# Provider side: this runs in the PROVIDER's pipeline, against every published
# consumer contract. A field rename fails the provider's build — which is the
# only moment the change is still cheap.
def test_satisfies_all_consumer_contracts(provider_app):
verify_contracts(provider_app, broker_url=BROKER, publish_results=True)- Unit tests for branching logic; do not write them for code that only delegates.
- Integration tests against the real database for anything involving queries, constraints or transactions.
- Contract tests at every service boundary, verified in the provider's pipeline.
- A handful of end-to-end tests covering the journeys that generate revenue. Keep the number small enough that you fix them rather than re-run them.
- Load and failure-injection tests for the paths whose degradation is an incident rather than an inconvenience.
Coverage measures which lines ran. It says nothing about which failures you would notice, and those are different questions.
One practice sharpens all of this: after each production incident, ask which test would have caught it and what it would have cost to run. Sometimes the answer is that no reasonable test would have, and that is fine — accept it and improve detection instead. But the pattern across a year of those answers tells you more about where your suite should be investing than any general rule about pyramid shape.