Most testing advice starts with "how to write tests." The more honest starting point is structural: your architecture decides what your tests cost, long before you write a single assertion. A monolith with constructor injection and in-memory adapters can run thousands of fast, deterministic tests. A service split with a shared database and no seams cannot — no matter how disciplined the team is. Test strategy is downstream of architecture; this article explains the shapes, the tradeoffs, and where each test belongs.
The pyramid: a cost model, not a diagram
The test pyramid (many fast unit tests, fewer integration tests, fewest end-to-end tests) is really a statement about cost per test:
layer runtime flakiness cost to write
unit ~1–10 ms near zero low
integration ~0.1–5 s low medium
end-to-end ~30 s–minutes significant high (infra, fixtures)Each layer up costs roughly an order of magnitude more to run and maintain. The pyramid's advice is a budget: spend most of your test budget at the bottom, because that's where confidence per dollar is highest. The pyramid only works if the bottom is cheap — and that is an architecture property, not a testing property. If your "units" need a database connection, they aren't units.
The trophy: a correction, not a contradiction
The testing trophy (Kent C. Dodds) redistributes the budget: fewer pure units, more
integration tests that exercise real behaviors through the seams, fewest end-to-end
tests. Its argument is honest: a unit test that asserts implementation details —
"service.calculate() was called with x" — locks in structure, not behavior. An
integration test that drives a behavior through real components gives more confidence
per test, because it tests the wiring the units deliberately ignore.
The two shapes are not enemies. The real division of labor:
- Unit tests verify the core's rules in isolation: a discount calculation, a state machine, a validation rule. They run in milliseconds and pin down the logic that must never silently change.
- Integration tests verify the wiring: request → controller → domain → repository → real database. This is where wiring bugs actually live — and wiring bugs are the majority of bugs in most systems.
The trophy is correct that a test only exercising a seam isn't worth much if the seam is where bugs breed. The pyramid is correct about the budget: integration tests need infrastructure, and every one of them is slower and flakier than a unit test.
Why architecture decides test cost
The structural rule is simple: every seam in your architecture is a place where tests can substitute a fake; every missing seam is a place where tests must bring real infrastructure.
- Constructor injection + ports (hexagonal architecture) means core tests supply an in-memory adapter — no DB, no network, milliseconds.
- Service locator / global state / singletons mean every test must arrange the whole world before it can test one behavior — setup code, shared mutable state, and order-dependent tests.
- A shared database across services means an "integration" test for one service can corrupt another service's assumptions; tests must run in isolation with coordination you now own.
- Message-driven flows mean every consumer test must handle duplicates and reordering — the architecture's delivery contract is now part of every test's setup.
Contract tests: the service-pair seam
Between services, neither unit nor integration tests are sufficient. An integration test that runs both services is an end-to-end test — slow, flaky, and it only proves the two current versions agree, not that future versions will. The tool for this seam is the contract test (Pact-style): the consumer captures its expectations as a contract — request shape, response shape, error cases — and the provider verifies itself against that contract without the consumer running.
consumer side: run against provider stub → generate contract (JSON)
provider side: run contract against real provider → verify it satisfies
every expectation, in CI, on every provider changeThe contract test is the mechanism that makes an API compatibility window real (see our article on API versioning): the provider cannot merge a breaking change without a failing contract test, so the breaking change becomes a decision, not an accident. Its cost is maintenance — every legitimate contract change requires a coordinated consumer update — which is exactly the coordination cost the service boundary creates, made visible instead of discovered in production.
The budget that matches the architecture
The honest allocation is a function of your structure, not a fixed ratio:
- Core logic with real rules → unit tests, as many as the rules warrant; they are your cheapest safety.
- Wiring with real infrastructure → integration tests through the seams, using fakes only at the seams themselves.
- Cross-service interfaces → contract tests, the only test that protects the compatibility window.
- User journeys → a thin end-to-end layer — one happy path per major flow — that exists to catch what the lower layers compose, not to be exhaustive.
Whatever the ratio, the architecture is upstream of it. Choose seams you can fake, own your data, and pin your interfaces with contracts — then the test budget follows the pyramid naturally. Choose structure that lacks seams, and no test strategy can make the suite fast, deterministic, and honest at the same time.