Module 1: From Units To Integration

3. The test pyramid

Description

In lesson 2 it became clear that you want both kinds of test: unit for their speed and precision, integration for the confidence in the joints. The next question is one of proportion: if you wrote a hundred tests for Reservo, how many of each kind? Testing engineering's best-known answer has the shape of a geometric figure —a pyramid— and it's neither a fad nor a pretty drawing: it's the logical consequence of properties you already know.

The test pyramid says: many unit tests at the base, fewer integration tests in the middle, few end-to-end (e2e) tests at the peak. The base is wide because unit tests are cheap —fast, deterministic, precise on failure—, so you can have hundreds without the suite hurting. The middle is narrower because each integration test costs more —slower, more setup, a failure harder to locate—, so you want enough to cover the seams that matter, not one for every possible combination. The peak is barely a point because an end-to-end test —the whole system running, with database, network, and everything— is extremely slow and fragile, valuable only for a handful of critical journeys. The shape isn't arbitrary: it comes from multiplying "how much each test costs" by "how many I need to cover its level".

Connection to the module: this lesson takes the two definitions from lesson 2 and turns them into a strategy of quantities. It's the bridge between "I know what each test is" and "I know how many to write and where". Lesson 4 will get into the detail of where the seams you decide to integrate live; lesson 6 will classify the types of integration that populate the middle of the pyramid; and lesson 7 will put numbers on the "they cost more" that justifies why the middle is narrower than the base. Here we install the shape and its why.

Analogy: the quality control of a bicycle factory

Think of a factory that assembles bicycles. It has three levels of quality control, and it spends on each one in inverse proportion to what it costs. First, the per-piece control: a machine measures every spoke, every ball bearing, every chain link, thousands per minute, for pennies each measurement. It's cheap and extremely fast, so all the pieces are measured —that's the base—. Second, the subassembly control: a worker builds the complete wheel and verifies that it spins without wobbling, or mounts the brake system and checks that it grips. It costs more —a person's time, several pieces together—, so not every imaginable combination is tested, only the subassemblies that really matter: the wheel, the brakes, the drivetrain. That's the middle. Third, the road test: someone gets on the finished bicycle and rides it around a track. It's the most expensive and slow —a whole bike, a person, a track—, so only a few from each batch are ridden, enough to trust that the whole model works. That's the peak.

No sane factory inverts this pyramid. If you tested every bicycle with a full road test and measured few pieces, you'd spend a fortune in time, you'd catch defects far too late (once the whole bike is built) and, when a road test failed, you wouldn't know if it was the spoke, the wheel, or the brake —you'd have to disassemble everything to find out—. The correct pyramid catches most defects at the cheapest and most precise level (the piece), uses the intermediate level for the joins that matter (the subassemblies), and reserves the expensive and ambiguous test (the road test) for final confidence over a few units. Your test suite is that factory: it measures every piece in bulk, verifies the key subassemblies, and road-tests the whole system only as much as strictly necessary.

The pyramid, level by level

Let's break down the three levels with Reservo in mind.

            /\
           /  \      e2e  —  few
          /____\      the whole system: slowest, fragile, ambiguous on failure
         /      \
        / integr.\   integration  —  some
       /__________\   BookingService + real SqliteBookingRepository: crosses the seam
      /            \
     /  unit tests  \  unit tests  —  many
    /________________\  BookingService with doubles, price_cents, refund_cents: fast and precise

The base: unit tests (many). Here live the tests of Reservo's pure logic (price_cents, refund_cents, overlaps) and those of BookingService with all its collaborators doubled. They're the wide base because each one is almost free: it runs in microseconds, depends on nothing external, and when it fails it points to a single place. You can cover every anchor, every edge, and every error path without the suite feeling it. It's where most of your confidence about the logic should live.

The middle: integration (some). Here live the tests that cross a seam with a real piece: BookingService with the SqliteBookingRepository, the real repository against its database, later a real HTTP call. They're fewer because they cost more —they open connections, touch disk, ask for setup and cleanup— and because their job isn't to re-verify the logic (the base already did that), but to verify the joints. You don't need an integration test for every business rule; you need one for every seam with a real risk of divergence. A few, well chosen, cover what the base can't see.

The peak: end-to-end (few). Here lives the whole system running like in production: the entire app, the real database, the network, maybe a browser. They're extremely valuable for confirming that a critical journey really works —"a member books Focus and receives the confirmation"—, but they're slow (seconds or minutes each), fragile (they fail for a thousand reasons unrelated to your code), and ambiguous on failure (which piece of the chain broke?). That's why they're a peak: a handful for the journeys you can't afford to break, and nothing more. (In this guide we don't write e2e with a web framework —that's testing-backend-applications-guide—; we name it so the map is complete.)

Worked example: the base of the pyramid, almost free

Let's see why the base can be so wide. Here is a slice of Reservo's unit tests: all the price anchors and all the refund ones, including the exact edges (48 h and 24 h) and the "just below" ones (47 h, 23 h). Twelve cases, each one a check of pure logic, without a single double or external resource.

# tests/test_pure_logic.py — the base: many cheap unit tests
from datetime import datetime, timedelta

import pytest

from reservo.models import Booking, Member, Room
from reservo.pricing import price_cents, refund_cents

FOCUS = Room(id="focus", name="Focus", capacity=4, hourly_cents=2500)
PRO = Member(id="m-ana", name="Ana", tier="pro")
BASIC = Member(id="m-leo", name="Leo", tier="basic")


@pytest.mark.parametrize("member, hours, expected", [
    (PRO, 3, 6000),     # 2500*3 = 7500, -20% -> 6000
    (PRO, 1, 2000),
    (PRO, 2, 4000),
    (BASIC, 3, 7500),   # no discount
    (BASIC, 1, 2500),
])
def test_price_cents(member, hours, expected):
    assert price_cents(FOCUS, member, hours) == expected


START = datetime(2026, 3, 10, 12)


@pytest.mark.parametrize("hours_before, expected", [
    (72, 6000),   # >= 48h -> 100%
    (48, 6000),   # exact edge -> 100%
    (47, 3000),   # just below -> 50%
    (36, 3000),   # [24, 48) -> 50%
    (24, 3000),   # exact edge -> 50%
    (23, 0),      # just below -> 0%
    (12, 0),      # < 24h -> 0%
])
def test_refund_cents(hours_before, expected):
    booking = Booking(id="bk-1", room_id="focus", member_id="m-ana",
                      start=START, end=START + timedelta(hours=3),
                      status="confirmed", price_cents=6000)
    now = START - timedelta(hours=hours_before)
    assert refund_cents(booking, 6000, now) == expected

What to expect. On my machine (Python 3.14.0, pytest 9.1.1):

python3 -m pytest tests/test_pure_logic.py -q
............                                                             [100%]
12 passed in 0.01s

Twelve tests in a hundredth of a second. That's the secret of the wide base: each one costs so little that you could have two hundred and the suite would still run in a blink. There's no connection to open, no file to create, no network response to wait for; just arithmetic in memory. Covering every edge of the refund policy —the exact 48, the 47 that slips past it by a hair, the 24, the 23— costs practically nothing, so everything gets covered. Now imagine each of these twelve, instead of computing in memory, opened a real database: lesson 7 will measure exactly how much you pay for that, and you'll see why the integration level can't be that wide.

Why the shape matters: the ice-cream-cone anti-pattern

The pyramid isn't the only shape a suite can take; it's the one that works. The opposite shape —the one that appears when a team doesn't think about proportion— is called the ice-cream cone: few unit tests on a thin base, some integration ones, and a huge scoop of end-to-end tests on top, because "they test like a real user does". It sounds reasonable and it's a disaster, for three reasons that come straight from the properties in lesson 2.

Slowness that punishes you. A suite dominated by e2e takes minutes or hours. A slow suite is run rarely —nobody waits twenty minutes on every save—, and a suite that's run rarely stops protecting: bugs slip in between runs. The wide base of unit tests exists precisely so that most of your confidence comes from tests you run constantly.

Fragility that lies to you. End-to-end tests fail for reasons unrelated to your code: the network blinked, an external service was slow, a piece of test data changed. When most of your suite is fragile, red failures stop meaning "there's a bug" and start meaning "run it again to see if it passes". That's the beginning of the end: a suite nobody trusts is a suite nobody looks at.

Ambiguity that costs you hours. When an e2e fails, the bug could be in any of the ten pieces of the chain or in any of its nine joints. Debugging it is an investigation. When a unit test fails, the bug is in that unit. Inverting the pyramid means trading hundreds of precise failures for a few failures that cost an afternoon each to track down.

The inverted pyramid doesn't catch fewer bugs in theory —an e2e sees everything—; it catches bugs late, expensive, and blurry, and it erodes the confidence that makes a suite useful. The correct shape pushes each check to the cheapest level that can perform it: the logic to the unit tests, the joints to integration, and only the final confidence of the complete journey to the peak.

Common mistakes

Reading the pyramid as an exact quota. What happens: someone takes "many, fewer, few" as a mandatory "70/20/10" and stresses about meeting the percentage. Why it happens: numbers are easier to follow than the criterion. How to detect it: if you're deleting useful integration tests to "not go over 20%", you confused the shape with an accounting rule. How to fix it: the pyramid is a shape, not a quota. The real rule is "push each check to the cheapest level that can honestly perform it". If that gives you 60/30/10 for your system, perfect; the exact proportion is dictated by your architecture, not a poster.

Putting in integration what is pure logic. What happens: someone tests the seven anchors of refund_cents with the real SqliteBookingRepository, "to make it more complete". Why it happens: the illusion that touching the real thing always tests better. How to detect it: if an integration test verifies a rule that doesn't depend on the seam —the refund arithmetic is the same with any repo—, it's at the wrong level. How to fix it: logic goes to the base (cheap, precise); integration is reserved for what only the real piece can reveal (the datetime serialization, not the 50% computation). Dropping a check a level down makes it faster and more precise without losing anything.

Trusting only the wide base and skipping the middle. What happens: someone has five hundred green unit tests, none of integration, and feels covered. Why it happens: the base is comfortable and fast to write; integration asks for setup. How to detect it: if no test touches the real piece at your critical seams, your pyramid has no middle —it's a base alone—, and it's exactly the lesson 1 trap (200 green unit tests, broken production). How to fix it: a pyramid without its integration band isn't a pyramid, it's a slab that doesn't cover the joints. Add the few integration tests that cover your risk seams; you don't need many, but you need some.

Exercises

Exercise 1 — Assign each test to its level. Place each one at the base (unit), the middle (integration), or the peak (e2e), and justify: (a) overlaps doesn't let you book an already-occupied room; (b) book really saves a row in the SQLite table and it can be read back; (c) a member opens the app, books Focus through the web interface, and receives the confirmation email; (d) refund_cents returns 3000 at 36 h before the start.

See solution
  • (a) overlaps — base (unit). Pure date logic: it takes four instants, returns a boolean. No collaborators, no seam. It goes at the base, cheap and precise.
  • (b) book saves a real row in SQLite — middle (integration). It crosses the seam between BookingService/repository and the real database. It verifies the joint, not the logic. It goes in the middle: a few that cover this seam are enough.
  • (c) the member books through the web and receives the email — peak (e2e). The whole system end to end: interface, server, database, email. Slow, fragile, ambiguous on failure. It goes at the peak, reserved for the critical journey. (And in this guide we don't even write it: it's testing-backend-applications-guide territory.)
  • (d) refund_cents at 36 h — base (unit). Pure arithmetic of the refund policy. Just like (a): base, cheap, one for each anchor and edge without the suite noticing.

The pattern: logic that computes in memory goes to the base; the joint with a real piece goes to the middle; the whole system journey goes to the peak. And there are many more of (a) and (d) than of (b), and many more of (b) than of (c) —that is the pyramid—.

Exercise 2 — Diagnose the ice-cream cone. A team has 15 unit tests, 10 integration, and 120 end-to-end. The suite takes 40 minutes, is run once a day, and half the red failures are "fixed" by running it again. Name the three ice-cream-cone symptoms that appear here and what change of shape would relieve them.

See solution

All three symptoms are present:

  1. Slowness that punishes. 40 minutes per run means it's only run once a day, so bugs slip in and live for hours before anyone sees them. The 120 e2e are the cause: each one is extremely slow, and they're the majority.
  2. Fragility that lies. "Half the failures are fixed by running again" is the signature of a fragile suite: red no longer means "there's a bug" but "maybe it was the network". Confidence in the suite erodes to zero.
  3. Ambiguity that costs. With 120 e2e and only 15 unit tests, when something fails there's almost never a precise unit test to flag it; you have to debug the whole system. Each failure is an investigation.

The change of shape: invert the proportion toward a pyramid. Drop most of the checks from e2e to unit tests (logic that today is tested by driving the whole system can almost always be tested in isolation, extremely fast and precise), keep an integration band for the real seams, and keep a handful of e2e only for the critical journeys. The suite would go from 40 minutes to seconds, red would mean something again, and failures would point to a place again.

Exercise 3 — How many integration tests for Reservo? Reservo has three real seams: BookingServiceSqliteBookingRepository (persistence), the repository ↔ its database (transactions), and later an HTTP boundary. A colleague proposes: "let's write one integration test for each business rule: one for each price anchor, one for each refund anchor, with the real repo". Is it a good idea? Explain from the pyramid.

See solution

It's not a good idea: it inflates the middle of the pyramid with tests that belong at the base. The price anchors (6000, 7500, 2000...) and the refund ones (6000, 3000, 0) are pure logic: price_cents and refund_cents compute the same no matter what repository is behind them. Testing them with the real SqliteBookingRepository verifies no new joint —the 20% discount computation doesn't cross the database seam—; it only makes each test hundreds of times slower and more fragile, in exchange for zero additional information. It's logic at the wrong level.

The right thing: the anchors go to the base, as parametrized unit tests (exactly the worked example, 12 in 0.01s). In the middle go the few integration tests that cover what only the seam reveals: that a booking saved and retrieved from the real repo keeps its fields (including the datetime trap), that get of a missing id raises against the real database, that a transaction rolls back. One or two per seam, not one per business rule. The pyramid stays wide at the bottom and narrow in the middle precisely by avoiding this duplication: each check at the cheapest level that can perform it.

Summary and next step

In this lesson you turned the two definitions from lesson 2 into a strategy of quantities. The test pyramid —many unit tests, fewer integration, few end-to-end— is not decoration: it's what comes from multiplying each test's cost by how many you need of each level. The base is wide because unit tests are almost free (you saw twelve in 0.01s); the middle is narrow because integration costs more and is only needed for the seams that matter; the peak is minimal because e2e is slow, fragile, and ambiguous. And you saw the anti-pattern: the inverted ice-cream cone, which punishes with slowness, lies with fragility, and charges with ambiguity.

Before moving on you should be able to: draw the pyramid and say what goes at each level for Reservo; explain why that shape, and not the inverted one, keeps a suite useful; and recognize when a check is at the wrong level (logic in integration, or a seam without any integration test).

The pyramid told you that you want some integration tests, for the seams that matter. The natural question is: where are those seams, exactly? How do you recognize them in the code, and how do you decide which to double and which to integrate? That's lesson 4: the seams, the points where two components connect.

Resources