Skip to content
Knowledge Hub

Unit, integration and e2e tests: choose the boundary by the bug you need to catch Basic

Estimated reading time: 7 min read

The API GET /quote?subtotal=100 returns the amount after discount. The pricing function has a unit test: input 100 gives 90.

After a route refactor, production returns 200 with the body { "total": 90 } while the client expects amount. The unit test stays green because it never went through the HTTP route. An e2e test through the UI might catch it, but slowly and without pinpointing the cause.

No test type “guarantees the whole system”. Choose the test boundary by the bug you want to catch. Teams use different names (Fowler also acknowledges the community has not agreed on terminology), so what matters is stating which components a test goes through and which it replaces.

This is the table to pin up when reviewing tests:

Bug to catch Cheapest layer that catches it Why
Wrong discount threshold, off-by-one, rounding Unit Pure logic, many variants, runs fast
A route renames a field (amount to total), wrong status code Integration (HTTP) Needs to go through routing, parser, serializer
Missing unique constraint, wrong SQL, broken migration, wrong isolation Integration with a real DB Mocks do not run SQL or constraints
Two services drift apart on an API/message schema Contract test Catches drift without standing up both
A route not registered in the deployment, wrong env, frontend calling the wrong field E2E / smoke Only shows up when every component is really connected
A race between a background job and a request Integration with proper wait signals Unit tests with mocks usually hide the race

Fowler’s principle: push tests down to the lowest possible layer; higher-layer tests only check what lower layers cannot. Broader tests run slower, and e2e tests are “notoriously flaky” (see flaky tests).

  • Unit: a small unit of logic (a function, an object) that knows nothing about HTTP or the DB. Fast feedback, narrow failure location.
  • Integration: checks the seams between parts. In the demo, it is a real HTTP server on port 0 called with fetch: routing, parser, the pricing function and JSON. This scope does not include a database or payment gateway.
  • E2E: a full journey within the chosen scope (client through the running system to the final result). State clearly whether external parts such as the payment provider are a sandbox, a stub or real calls.

The pyramid is a picture of cost, not a 70/20/10 quota. Two alternative models worth knowing:

Model Focus Fits when
Pyramid (Cohn, Fowler) Many unit tests, fewer integration tests, very few e2e Business-logic-heavy code with few infrastructure dependencies
Trophy (Kent C. Dodds) “Write tests. Not too many. Mostly integration” (a Guillermo Rauch quote Dodds builds on) plus static analysis at the base Frontends/apps where tests are more trustworthy the closer they resemble real usage
Honeycomb (Spotify) Mostly integration tests per service; a few implementation-detail tests; as few integrated tests (through other services) as possible Microservices: the value lies in the interaction points between services

Choose based on where bugs actually occur. A CRUD service whose logic lives in SQL needs more integration tests against a real DB than a pure computation library.

Loading diagram…

Test doubles (fakes, stubs, mocks) help with isolation but must be used deliberately. A mocked repository returning exactly the data we expect does not check unique constraints, SQL, migrations or transaction isolation. For persistence risks, a test against real PostgreSQL is worth more than many mock tests.

Testcontainers provides throwaway database instances running in containers, so every test run gets a clean, production-like DB:

import { PostgreSqlContainer } from '@testcontainers/postgresql';
const pg = await new PostgreSqlContainer('postgres:17').start();
// run the real migrations against pg.getConnectionUri(), run the tests, then:
await pg.stop();

A practical approach: start the container once for the whole suite, run the real migrations, and isolate each test with its own schema or transaction. The cost is startup time and the need for a container runtime in CI.

When two services exchange data over an API or messages, e2e tests to catch schema drift are expensive and flaky. Consumer-driven contract tests let the consumer describe the data it needs, and the provider runs those tests in its own pipeline.

Schema drift is caught in the provider’s CI, without standing up both services at once. Fowler describes this approach in the Practical Test Pyramid article.

Type Use when Avoid when
Unit Business rules with many branches Only to raise coverage, asserting implementation details that change easily
Integration + real DB Risk lies in SQL, constraints, migrations, transactions Testing pure logic: use unit tests for speed
Contract Many teams/services exchange schemas Two modules in the same repo, released together
E2E A few vital journeys (login, payment) Repeating every discount combination

Run the demo (Node test runner, no database needed):

Terminal window
npx --prefix examples ts-node examples/src/testing-levels/quote.test.ts

Expect a summary of pass 2, fail 0. Two code mutations show that each layer gives a different signal:

Code change Unit Integration
{ amount } to { total: amount } pass fail
subtotal >= 100 to > 100 fail fail

The demo also checks for a missing subtotal: without a guard, Number(null) becomes 0 and bad input looks valid. Real money needs its own type and rounding policy; this article only uses it to look at test boundaries.

Layered testing

  • Unit tests give fast feedback and a narrow failure location.
  • Integration tests catch wiring, schema, query and contract bugs at real boundaries.
  • E2E tests confirm a few important journeys in a near-real configuration.

What has to be managed

  • Broader tests need environments, data and cleanup; running many of them gets slow and flaky.
  • Too much mocking makes tests pass according to the mock’s assumptions.
  • Repeating the same case at every layer adds maintenance cost without adding signal.
  1. Each test states which components it goes through and which it replaces.
  2. Business rules with many branches live in unit tests; do not repeat them in e2e.
  3. Every public route has at least one integration test for status, body and input errors.
  4. Persistence risks (constraints, migrations, transactions) are tested against a real DB, for example via Testcontainers.
  5. Boundaries between services have contract tests instead of relying on e2e.
  6. E2E is limited to a few vital journeys, with a smoke test after deploy.
  7. Data is isolated between tests; no dependence on run order; dynamic ports.

Self-check: (1) Why does the unit test quote(100) not detect the response renaming amount to total? (2) At which layer should a PostgreSQL unique constraint bug be caught? (3) When is the honeycomb more sensible than the pyramid? (4) Where does a contract test replace e2e?