Module 8: Project Contract And Integration For Reservo

5. The end-to-end integration test: `book`→`get`→`cancel`

Description

With the contract green on both sides you have the first layer of the guarantee. Now you build the second: the third deliverable, the end-to-end integration test. Here BookingService stops talking to specs and clauses and talks to the real SqliteBookingRepository, in a live flow: it books, saves, cancels, re-reads. It's the repository's seam crossed for real, with the piece that runs in production on the other side, exercised by the complete orchestrator. Where the contract inspects the shape of each datum, the integration uses the data in the real flow —and that catches a class of bug no clause sees—.

Lesson 1's method comes due here: what to double and what to keep real. In this integration the repository goes real —it's the seam you test—, and the clock, the payment, and the email go doubled —the non-deterministic, the external—. That mix isn't laziness or taste: it's what makes the test measure the BookingService↔repository collaboration without inheriting the cost and fragility of charging cards or waiting for the clock to advance. You're going to write the bookget flow (save and re-read a booking) and the complete bookcancelget flow (book, cancel with refund, re-read the cancelled status), both against real SQLite, and see them green. And you're going to understand, with module 5's lens, why this flow catches what the contract's inspection can't.

Connection to the module: this lesson produces deliverable 3 in its basic form; lesson 6 hardens it with the isolation. The flow you write here is the one you'll wrap, in lesson 6, in a fixture that creates and destroys the resource or in a transaction that's reverted, so each run starts clean. And it's the complement of the contract from lessons 2 to 4: together, contract and integration cover the two ways the seam can fail —a divergence in an enumerated behavior (contract) and a bug of use in the real flow (integration)—. Here you see the second half of that coverage take shape.

Analogy: the test drive of the assembled car

A car factory tests each part separately: the engine on a bench, the brakes in a simulator, the steering with a torque wrench. Each part passes its test. But before sending the car to the street, it does one more thing: a test driver gets into the assembled car and takes a lap around the track. They don't re-measure the engine's compression or the brakes' pressure —that was already done—; they drive. They accelerate and feel whether the transmission engages, brake in a curve and feel whether the steering responds under load, shift a gear and listen for whether something vibrates. The lap around the track tests what no bench test can: that the parts, together and in motion, collaborate. A car with every part approved can fail on the first curve because two correct parts don't fit under real conditions.

The end-to-end integration is the lap around the track. The contract was the bench tests: each method of the repository, each clause, verified separately. Now you get BookingService into the assembled car —with the real SqliteBookingRepository mounted— and take a lap: book accelerates (creates and saves the booking), cancel brakes in the curve (reads the booking back, calculates the refund, saves the cancelled status), get confirms the car ended up where it should. You don't inspect each datum; you drive the flow. And if two correct parts don't fit under real load —the start the repository returns in a shape cancel can't use—, the lap around the track discovers it where the bench test didn't look: in the curve, with the car in motion.

The third deliverable, written

Start with the test's infrastructure: the fixture that gives a fresh real repository and the function that assembles the service with the right mix of real and doubled. This is the direct application of lesson 1's table.

# tests/test_end_to_end_integration.py — THE INTEGRATION (deliverable 3)
import sqlite3
from datetime import datetime

import pytest

from reservo.calendar import Calendar
from reservo.doubles import FixedClock, SpyEmailSender, StubPaymentGateway
from reservo.models import Member, Room
from reservo.services import BookingService
from reservo.sqlite_repo import SqliteBookingRepository

FOCUS = Room(id="focus", name="Focus", capacity=4, hourly_cents=2500)
ANA = Member(id="m-ana", name="Ana", tier="pro")
START = datetime(2026, 3, 10, 9)
END = datetime(2026, 3, 10, 12)          # Focus 3 h
CLOCK = datetime(2026, 3, 1, 9)          # 9 days before -> full refund (6000)


@pytest.fixture
def repo():
    conn = sqlite3.connect(":memory:")          # ephemeral DB, clean per test
    yield SqliteBookingRepository(conn)
    conn.close()                                # destroyed when the test ends


def make_service(repo):
    # Real the seam we test (the repository); doubled the non-deterministic
    # and the external (clock, payment, email).
    return BookingService(Calendar(), FixedClock(CLOCK),
                          StubPaymentGateway(ok=True), SpyEmailSender(), repo)

Look at the repo fixture carefully, because it embodies two decisions. The first: the repository is real —a SqliteBookingRepository over a :memory: database—, because it's the seam the integration tests. The second: it's fresh per test —each test gets a new connection, and it's closed when it ends—, a raw preview of the isolation lesson 6 will do rigorously. And in make_service, the table's mix: FixedClock (frozen clock, non-deterministic), StubPaymentGateway (doubled payment, external), SpyEmailSender (doubled email, external), and the real repo that arrives by parameter. Real the seam, doubled the rest.

Now the two flows. The first is the narrow integration bookget: book and re-read, verifying that the booking crossed the seam and came back intact.

def test_book_then_get_persists_the_booking(repo):
    service = make_service(repo)
    booking = service.book(FOCUS, ANA, START, END)
    saved = repo.get(booking.id)
    assert saved.price_cents == 6000
    assert saved.start == START             # datetime intact (get reconstructs it)
    assert saved.status == "confirmed"

The second is the complete flow bookcancelget: book, cancel, and re-read the cancelled status. It's the one that exercises the use of the read datum —cancel doesn't just read the booking, it uses it to calculate the refund—.

def test_book_cancel_get_full_flow_against_real_sqlite(repo):
    service = make_service(repo)
    booking = service.book(FOCUS, ANA, START, END)   # writes the booking
    refund = service.cancel(booking.id)              # reads, calculates, saves cancelled
    assert refund == 6000                            # full refund (9 days before)
    assert repo.get(booking.id).status == "cancelled"   # the final status, re-read

The clock is at CLOCK = 2026-03-01, nine days before the booking's start (START = 2026-03-10), so the notice is 216 hours, well above the 48 of the first band, and the expected refund is the full one: 6000. Freezing the clock is what turns that anchor into a fixed datum of the test —if the clock were real, the refund would depend on when you run the test—.

Worked example: the two flows, in green

Let's run the integration:

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

python3 -m pytest tests/test_end_to_end_integration.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items

tests/test_end_to_end_integration.py::test_book_then_get_persists_the_booking PASSED [ 50%]
tests/test_end_to_end_integration.py::test_book_cancel_get_full_flow_against_real_sqlite PASSED [100%]

============================== 2 passed in 0.01s ==============================

Two greens, and with this the third deliverable exists. The first test demonstrates that a booking created by book is saved in real SQLite and comes back intact —price_cents == 6000, start as the original datetime, status == "confirmed"—. The second demonstrates the complete cycle: book writes, cancel reads the booking back, calculates the full refund of 6000 (nine days' notice), saves the cancelled status, and get confirms it ended up cancelled. All against a real database, crossing the seam at every step. The lap around the track came out clean: BookingService and the SqliteBookingRepository don't just resemble each other —they collaborate—.

Why the flow catches what the contract doesn't

Here's the unique value of this deliverable, and it deserves to be broken down, because it explains why the integration isn't redundant with the contract even though both touch the same repository.

The second test exercises something the contract can't: the use of the read datum. cancel doesn't limit itself to recovering the booking; it takes its start and subtracts it from the clock's time to calculate the notice (refund_cents does (booking.start - now)). That subtraction is date arithmetic, and it only works if start is a datetime. If SqliteBookingRepository.get had the module 1 bug —returning the start as a str—, this flow would blow up with a TypeError: unsupported operand type(s) for -: 'str' and 'datetime.datetime', not on an assertion but at the heart of cancel.

And this is the deep part: the integration would catch that bug even if your contract had a gap right there. The contract inspects fields with ==; if your clause 1 compared only status and price_cents and forgot the start, the contract would pass in green —the fake and the real one coincide in the fields you looked at—, and the datetime bug would live in the gap. The integration doesn't depend on your having thought to verify the start: it doesn't inspect, it uses. cancel subtracts the start, and the subtraction blows up with the str, without any assertion of yours having to point at the field. The integration catches the bug through use, not through inspection.

That's the exact complementarity the capstone asks you to deliver complete: the contract protects you from the divergences you enumerated; the integration protects you from the ones you didn't think to enumerate but the real flow triggers. The contract certifies that the pieces meet the clauses you wrote; the integration verifies that they really collaborate, even in what you didn't write. That's why the three deliverables are three, and not two: the contract from both sides is one layer, the end-to-end integration is the other, and neither makes the other unnecessary.

Narrow and wide: two integrations, one flow

Notice you delivered two integration tests, and they're not the same. The first, bookget, is a narrow integration: it crosses the seam once in each direction (write, read) and verifies that the datum survives the trip. The second, bookcancelget, is a wide integration: it travels a complete business flow —book, cancel, re-read— where the datum written by one step is read and used by the next. The narrow one catches bugs of shape (the datetime that changes type on crossing); the wide one catches, in addition, bugs of use (the cancel that can't operate on what it read).

Both are worth it, and that's why both are in the delivery. If you only had the narrow one, you'd verify that the datum travels well but not that BookingService can do something with it in a real flow. If you only had the wide one, you'd cover the use but lose the pointed verification that a book followed by a get keeps every field. Together they give the complete photo of the seam: the datum crosses intact (narrow) and the system uses it well in a live flow (wide). Choosing to have both is part of the method the capstone evaluates.

Common mistakes

Doubling the repository "to make it faster" and losing the integration. What happens: someone, out of unit-test reflex, uses FakeBookingRepository in the integration test because "it runs faster". Why it happens: doubling everything is the unit-testing habit. How to detect it: if in your integration no real piece crosses the seam, you tested no joint —it's a unit test with an integration name—. How to fix it: the seam under test (the repository) goes real, always. What's doubled is the external and non-deterministic around it (payment, clock, email), not the seam itself. With :memory: the real repository is almost as fast as the fake, so speed isn't an excuse to double it.

Leaving the clock real and having a test that fails depending on the day. What happens: someone uses a real datetime.now() in the cancel integration, and the refund comes out different depending on when the test runs. Why it happens: not doubling the clock seems "more real". How to detect it: if the test passes today and fails tomorrow without the code changing, you depend on time. How to fix it: the clock is non-deterministic and goes doubled even though the repository is real. FixedClock(CLOCK) freezes the time, and that turns the refund into a function only of the test's data —6000 because START is 216 hours after CLOCK, not because today is such a day—. Real the seam, doubled the non-deterministic: the rule has no exception for the clock.

Confusing "the integration passed" with "the contract is unnecessary". What happens: with the two flows green, someone concludes the contract is no longer needed. Why it happens: the integration touches the real repository and gives a lot of confidence. How to detect it: ask yourself whether your two flows exercise each clause of the contract. The happy flow bookcancelget never exercises "get of an absent id raises" nor "find_by_room returns only that room" —it uses ids that exist and a single room—. How to fix it: keep the contract (it covers the clauses the integration doesn't travel) and the integration (it covers the use the contract doesn't exercise). They're distinct layers; the capstone asks for both because neither is enough alone.

Exercises

Exercise 1 — Predict where it blows up. Without running anything, imagine that SqliteBookingRepository.get has the module 1 bug (returns start as a str, without fromisoformat). Which of the two integration tests would fail first, with what error, and at what conceptual point of the flow?

See solution

test_book_cancel_get_full_flow_against_real_sqlite would fail, the wide flow, with a TypeError. The exact point: cancel calls refund_cents(booking, booking.price_cents, now), and inside, refund_cents does hours_until = (booking.start - now).total_seconds() / 3600. With the bug, booking.start is the str '2026-03-10T09:00:00' and now is a datetime, so the subtraction raises TypeError: unsupported operand type(s) for -: 'str' and 'datetime.datetime'. The flow blows up when using the start, not when reading it.

The first test, test_book_then_get_persists_the_booking, would also fail, but for another reason and in another way: its assertion saved.start == START would give False (str != datetime) and report an AssertionError, not a TypeError. The difference is instructive: the narrow test catches the bug through inspection (it compares the field and sees it differs); the wide one catches it through use (it tries to operate with the field and can't). The wide one is the one that resembles what would happen in production —a cancel blowing up—, and that's why its red is the most valuable.

Exercise 2 — Change the refund anchor. The test verifies refund == 6000 with CLOCK = datetime(2026, 3, 1, 9). What value of CLOCK would make the refund be 3000, and why? And to make it 0? Use Reservo's anchors (72 h→6000, 36 h→3000, 12 h→0).

See solution

refund_cents calculates hours_until = (booking.start - now) and applies: >= 48 h → full refund (6000); 24 <= hours_until < 48 → 50% (3000); < 240. With START = 2026-03-10 09:00:

  • For 3000: you need a notice in [24, 48) hours, for example 36 hours. That's CLOCK = datetime(2026, 3, 8, 21) (36 h before 9:00 on the 10th). Then hours_until = 36, falls in 24 <= 36 < 48, and refund_cents returns 6000 * 50 // 100 = 3000.
  • For 0: you need a notice less than 24 hours, for example 12 hours. That's CLOCK = datetime(2026, 3, 9, 21) (12 h before). Then hours_until = 12 < 24, and refund_cents returns 0.

What this shows: the FixedClock is what lets you choose which anchor you exercise, by setting it to whatever notice you want. That's why the clock is doubled even though the repository is real —it's non-deterministic, and freezing it turns each anchor into a datum of the test—. It's the table's rule in action inside the integration flow.

Exercise 3 — Narrow versus wide, in your suite. Classify each of these Reservo tests as a narrow or wide integration, and say what class of bug each catches: (a) bookget verifying that the booking comes back intact; (b) bookcancelget verifying the refund and the cancelled status; (c) saving directly with repo.save and re-reading with a raw SELECT over the table.

See solution
  • (a) bookget — narrow. It crosses the seam once in each direction (write, read) without traveling a long business flow. It catches bugs of shape: that the datum survives the trip intact (the datetime that doesn't change type, the price that crosses as an integer). It verifies through inspection: it compares field by field what came back.
  • (b) bookcancelget — wide. It travels a complete business flow where the datum written by one step is read and used by the next (cancel subtracts the start). It catches bugs of use, in addition to those of shape: that BookingService can really operate on what the repository returns. It verifies through use: it exercises the flow and sees whether something blows up.
  • (c) repo.save + raw SELECT — narrow (and very narrow). It touches a single real piece (the repository) against its real resource (the table), without going through BookingService. It catches bugs of shape in the seam itself: what exactly is saved in the table, with a tool independent of the repository. It's the narrowest integration possible —one piece against its boundary—.

The lesson: a healthy integration suite mixes narrow ones (cheap, precise, catch shape) and wide ones (travel real flows, catch use). The capstone delivers at least one of each to cover the two classes of seam bug.

Summary and next step

In this lesson you built the third deliverable: the end-to-end integration test. You wrote two flows against the real SqliteBookingRepositorybookget, which verifies that a booking crosses the seam and comes back intact, and bookcancelget, which travels the complete book-cancel-re-read cycle— and saw them green. You applied lesson 1's table: real the seam you test (the repository), doubled the non-deterministic and the external (clock, payment, email). With the lap around the track of the assembled car you understood that the integration tests what no bench test can —the parts together and in motion— and, above all, why it catches what the contract doesn't: the flow uses the datum (cancel subtracts the start) where the contract only inspects it, so it catches a bug of use even if the contract had a gap right there. And you distinguished the narrow integration (shape) from the wide one (use), and why the delivery carries both.

Before moving on you should be able to: write an end-to-end integration with the right mix of real and doubled, and justify each decision; explain why the flow catches bugs of use that the contract's inspection doesn't see; and distinguish a narrow integration from a wide one by the class of bug each catches.

The integration works, but it has a fragility you haven't attended to yet: it uses a real resource, and real resources persist. The fresh-per-test fixture you wrote is a first patch; it's missing being done rigorously. In lesson 6 you harden deliverable 3 with module 7's isolation: you'll see first how a test contaminates the next when they share real state, and then the two cures —a fixture that creates and destroys an ephemeral database, and a transaction that's reverted per test— so each run starts clean.

Resources

  • sqlite3 — DB-API for SQLite (Python documentation) — the reference for the real resource the integration crosses; Connection.execute, commit, and connect(":memory:") are the pieces with which book, cancel, and get operate against a real database.
  • datetime.fromisoformat — Python documentation — the conversion that lets cancel subtract the start; without it, the wide flow would blow up with the TypeError from exercise 1.
  • pytest documentation — How to use fixtures — the reference for the repo fixture that delivers a fresh real repository per test; the scaffolding lesson 6 hardens with isolation.
  • Martin Fowler — IntegrationTest — the framework that explains why an integration verifies the real collaboration and catches bugs of use that the isolated verification of each component may not enumerate; the foundation of the contract/integration complementarity.