Module 6: Real Boundaries Db Files Http

8. Mini-project: Reservo's three boundaries

Description

The module's close is here, and with it the practical synthesis. You went through Reservo's three boundaries separately —the database with its transaction, the file with tmp_path, the HTTP with http.server— and in lesson 7 you distilled the rule that governs when to touch each one for real. Now you're going to bring them together in a single flow, the most realistic one Reservo produces: a member books, the charge goes out over HTTP to the gateway, the booking is persisted in SQLite, and then someone exports that room's bookings to a file for a report. Three real boundaries, crossed in sequence, verified in one test. It's the module's final exam: if you can write this test and explain what you left real and what you doubled at each boundary, you master the discipline.

This mini-project introduces nothing new; it composes what you already know. The HttpPaymentGateway against an http.server from lesson 6; the SqliteBookingRepository in a temporary file with the persistence from lessons 3 and 4; the CSV export with tmp_path from lesson 5; and lesson 7's rule applied three times —at each boundary you decide what goes real and what goes doubled—. The value of bringing them together is seeing that the three coexist in a flow without getting in each other's way, and feeling how a broad integration test can cross several real boundaries at once and still be fast and deterministic if you respect the three keys. By the end you'll have a test that, in half a second, exercises the HTTP protocol, a database transaction with on-disk persistence, and the serialization to a file —everything this module taught, green—.

Connection to the module: this lesson is module 6's capstone. It brings together the three boundaries and the decision rule in a single deliverable, and leaves you at module 7's door. Because in writing this test you're going to notice something uncomfortable: you created a database in a file, spun up a server, wrote a CSV —and all of that has to be set up beforehand and cleaned afterward—. In this module we did it by hand, with tmp_path and a server fixture, just enough for it to run. How to make that setup and cleanup systematic and leak-proof —resource fixtures, isolation with rollback, keeping each test independent even if it shares real state— is exactly module 7. This mini-project is the last test with the isolation done by hand; the next module industrializes it.

The challenge

Write one integration test that crosses Reservo's three real boundaries in a flow, and turn it in with its green pytest output and the justification of your decisions. Specifically, the test must:

  1. HTTP boundary (real). Charge through a real HttpPaymentGateway that makes a POST to a fake http.server spun up in a thread with an ephemeral port.
  2. Database boundary (real). Persist the booking in a SqliteBookingRepository over a file (to test on-disk persistence), and check that it survives by reading it with a new connection.
  3. File boundary (real). Export the room's bookings to a CSV with tmp_path, and import them back, verifying the round-trip.

And it must respect lesson 7's rule: real at the three boundaries it examines (they're the subject of this broad test) and doubled at what it doesn't examine —the clock (FixedClock), the email (SpyEmailSender)—. Before looking at the solution, try writing it yourself: set up the server with a fixture, use tmp_path for the database file and for the CSV, and chain charge → persist → reopen → export → import.

The solution

Here's the complete test. Read it in blocks —the server and its fixture at the top (identical to lesson 6's), and then the flow that crosses the three boundaries—.

# tests/test_three_boundaries.py — Reservo's three real boundaries in a flow
import json
import sqlite3
import threading
from datetime import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer

import pytest

from reservo.calendar import Calendar
from reservo.doubles import FixedClock, SpyEmailSender
from reservo.export import export_bookings, import_bookings
from reservo.http_gateway import HttpPaymentGateway
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")
CLOCK = datetime(2026, 3, 1, 9)


class FakeGatewayHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        body = json.loads(self.rfile.read(length).decode("utf-8"))
        reply = json.dumps({
            "id": "rcpt-http-1", "ok": True, "amount_cents": body["amount_cents"],
        }).encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(reply)))
        self.end_headers()
        self.wfile.write(reply)

    def log_message(self, *args):
        pass


@pytest.fixture
def gateway_url():
    server = HTTPServer(("127.0.0.1", 0), FakeGatewayHandler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        yield f"http://127.0.0.1:{server.server_address[1]}"
    finally:
        server.shutdown()
        thread.join()
        server.server_close()


def test_reservo_crosses_all_three_real_boundaries(tmp_path, gateway_url):
    db_path = tmp_path / "reservo.db"

    # --- HTTP BOUNDARY + DB BOUNDARY: charge over HTTP, persist in SQLite ---
    service = BookingService(
        Calendar(), FixedClock(CLOCK),
        HttpPaymentGateway(gateway_url),                    # the charge crosses real HTTP
        SpyEmailSender(),
        SqliteBookingRepository(sqlite3.connect(db_path)),  # persists in a file
    )
    a = service.book(FOCUS, ANA, datetime(2026, 3, 10, 9), datetime(2026, 3, 10, 12))
    b = service.book(FOCUS, ANA, datetime(2026, 3, 12, 9), datetime(2026, 3, 12, 12))

    # The booking persisted: it's read with a NEW connection to the same file.
    reopened = SqliteBookingRepository(sqlite3.connect(db_path))
    assert reopened.get(a.id).price_cents == 6000
    assert {x.id for x in reopened.find_by_room("focus")} == {a.id, b.id}

    # --- FILE BOUNDARY: export to CSV and read back ---
    csv_path = tmp_path / "export.csv"
    export_bookings(reopened.find_by_room("focus"), csv_path)
    assert csv_path.exists()

    restored = import_bookings(csv_path)
    assert {x.id for x in restored} == {a.id, b.id}
    assert all(x.price_cents == 6000 for x in restored)
    assert all(isinstance(x.price_cents, int) for x in restored)

Follow the flow, which is that of a day at Reservo. BookingService is built with two real boundary pieces —the HttpPaymentGateway (which will charge over HTTP) and the SqliteBookingRepository over a file (which will persist on disk)— and two doubles —the clock and the email, which aren't boundaries we examine—. Two bookings are made: in each, the charge crosses the HTTP boundary to the server, and the booking crosses the database boundary to the file. Then a new connection to the same file (reopened) is opened and read: if the booking is there, it's because save's commit truly persisted it on disk —the proof from lessons 3 and 4—. Finally, Focus's agenda is exported to a CSV with tmp_path and imported back: the file boundary's round-trip from lesson 5, with the price_cents that comes back as an integer because import_bookings reconverts it. Three boundaries, one flow.

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

python3 -m pytest tests/test_three_boundaries.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item

tests/test_three_boundaries.py::test_reservo_crosses_all_three_real_boundaries PASSED [100%]

============================== 1 passed in 0.53s ===============================

Green, in half a second. That single green certifies a lot: that the charge was serialized to JSON, traveled over TCP to a real server, and came back with its receipt; that the booking was serialized to a row, written to a SQLite file, committed with a commit, and survived our opening a new connection; and that the bookings were exported to text in a CSV on the disk and reimported with their types reconstructed. Three real boundaries, exercised end to end, without a single double at any of the three seams under examination —and still fast and deterministic, because each resource is local and ephemeral: not :memory:, but a file in tmp_path that deletes itself, and a server in a thread with an ephemeral port—.

Why what stayed real stayed real (and the rest doubled)

The deliverable asks to justify the decisions, so let's make them explicit boundary by boundary —it's lesson 7's rule applied three times—:

  • The HTTP boundary stayed real (a fake http.server, but real HTTP) because one of the things this test examines is that the charge crosses the network correctly: that the client builds the request, sends it, and parses the response. Doubling it with a StubPaymentGateway would have skipped exactly that protocol. It's real, but local and ephemeral —we spin up the server ourselves—, not the production gateway that would charge money.
  • The database boundary stayed real and over a file (not :memory:) because the test examines persistence on disk: that the booking survives opening a new connection. That :memory: can't test (a reopened :memory: database is new and empty), so here the file with tmp_path is the correct choice —and tmp_path deletes it itself—.
  • The file boundary stayed real (a CSV in tmp_path) because the test examines the export/import: the serialization to text and the reconstruction of types. Doubling it would make no sense —it's one of the three seams under examination—.
  • The clock and the email stayed doubled (FixedClock, SpyEmailSender) because they aren't boundaries this test examines. The real clock would introduce non-determinism (the time changes); the real email would have an external effect (sending messages) without adding anything to what we test. They're passing-through collaborators: shadow, not beam.

This test is broader than lesson 7's —it lights up three boundaries at once, not one— and that's legitimate: sometimes you want to verify a complete realistic flow. The key is that it still respects the rule: real at the seams it examines, doubled at the ones it doesn't, and all the real resources are local and ephemeral so as not to inherit production's cost or risk. It's a broad integration, not an end-to-end against real systems.

Common mistakes

Using :memory: in the test that examines on-disk persistence. What happens: someone builds the flow with sqlite3.connect(":memory:") and the "reopen with a new connection" assertion passes by accident or fails confusingly. Why it happens: :memory: is the habit. How to detect it: if the test says it tests that the booking survives a reopen, but uses :memory:, it doesn't test it —a new connection to :memory: is an empty database, and get would raise KeyError—. How to fix it: when on-disk persistence is part of the examination, the database goes in a file (tmp_path / "reservo.db"), as in the solution. :memory: is for when you don't test durability.

Leaving the production gateway real "to make it more realistic". What happens: someone changes the fake http.server for the real staging payment gateway. Why it happens: the temptation of "more real". How to detect it: if your test can charge money, depends on a live external system, or lags because of the real network, you crossed from a broad integration to a fragile end-to-end. How to fix it: the HTTP boundary is examined with a local and fake server that exercises the protocol without production's effects or dependency. Real in the protocol, yours in the control.

Forgetting to set up or clean a resource. What happens: someone spins up the server but doesn't shut it down, or writes the CSV to a fixed path that isn't deleted; the suite leaves live threads, occupied ports, or loose files. Why it happens: with three resources in play, it's easy for one to slip away. How to detect it: runs that hang at the end, occupied ports, or files that appear in the project. How to fix it: each real resource goes with its setup and its cleanup —the server in a fixture with try/finally, the database and the CSV in tmp_path that deletes itself—. That this setup-and-cleanup is still manual is precisely what module 7 turns into a systematic discipline.

Exercises

Exercise 1 — Add the fourth verification. The test checks that the price_cents survives the three boundaries as an integer. Add an assertion that verifies what happened to the start of a booking imported from the CSV, and explain why that result is consistent with the whole module.

See solution

The assertion would be that start came back as text, not as datetime:

focus_a = next(x for x in restored if x.id == a.id)
assert isinstance(focus_a.start, str)
assert focus_a.start == "2026-03-10T09:00:00"

It's consistent with the whole module because start crossed two boundaries that serialize it to text and none that reconstruct it. First, the database: SqliteBookingRepository.save stored it with .isoformat() and get returned it as str (the datetime bug from module 1). Then, the file: export_bookings wrote it as text and import_bookings left it as str (start=r["start"], without datetime.fromisoformat). In neither of the two steps did anyone reconvert it to datetime. The price_cents, on the other hand, came back as int because import_bookings re-wraps it with int(...) —and in SQLite it was already native INTEGER—.

The underlying lesson, which is the thread of the whole module: the boundaries flatten everything to text; the types are preserved only where your code explicitly reconstructs them. A value that crosses several boundaries without being reconstructed accumulates "text on text" and ends up far from its original type. That's why, if Reservo needed start as datetime after importing, it would have to be reconstructed in the importer —the same fix, at each boundary that flattens it—.

Exercise 2 — Split the capstone into focused tests. The mini-project's test lights up the three boundaries at once, which is legitimate for a realistic flow. But for diagnosis, sometimes one test per boundary is preferable. Describe the three focused tests you'd split this flow into, saying what stays real and what doubled in each.

See solution

Three focused tests, each with the beam on one boundary:

  1. HTTP boundary test. Real: the HttpPaymentGateway against a fake http.server. Doubled: the repository (FakeBookingRepository), the clock, the email. It verifies that book charges the correct amount crossing the network. (It's test_http_boundary_real_repo_doubled from lesson 7.)
  2. Database boundary test. Real: the SqliteBookingRepository over a file (tmp_path), with the reopen-with-a-new-connection verification. Doubled: the gateway (StubPaymentGateway), the clock, the email. It verifies that the booking persists on disk.
  3. File boundary test. Real: the export/import to a CSV with tmp_path. The data can come from a FakeBookingRepository (it isn't the boundary under examination). It verifies the round-trip and the reconstruction of types.

Each is fast, deterministic, and when it fails it points to a single boundary —the diagnosis is immediate—. The mini-project's broad test has its place (verifying that the complete realistic flow works), but when something breaks, the three focused ones tell you where. A mature team usually has both: several per-boundary focused tests (the majority) and a few broad ones that verify complete flows. It's the pyramid applied within integration: many narrow and cheap, few broad and valuable.

Exercise 3 — The resource that slipped away. Imagine you remove the try/finally block from the gateway_url fixture and only leave the server startup. The test stays green the first time. Explain what problem appears when running the whole suite several times, and why it connects with module 7.

See solution

Without the try/finally, the server never shuts down: every time the fixture runs, it spins up an HTTPServer and a thread that stay alive —the serve_forever loop keeps running, the port stays occupied, the daemon thread doesn't join—. The first test passes because the server works; the problem is cumulative. When running the whole suite (or several times), servers and threads pile up unclosed: occupied ports that, even if ephemeral, aren't freed; threads that consume resources; and, depending on the system, warnings about unclosed resources or runs that take a while to finish cleanly. It's a resource leak: the real state you created (a server) wasn't cleaned, and it leaks from one test into the environment of the following ones.

It connects directly with module 7 because that's its central topic: how to set up and tear down real resources systematically and without leaks. The try/finally (or the yield with its cleanup block) is the manual version of that discipline —you create the resource, hand it over, destroy it no matter what—. Module 7 generalizes it to resource fixtures (a temporary database created and destroyed around each test), to rollback as a technique to leave the database as it was, and to the principle that each integration test must be independent and repeatable even if it touches real state. This mini-project is the last time we set up and clean by hand; the omission in this exercise is exactly the kind of leak that module 7's isolation eliminates at the root.

Summary and next step

In this mini-project you closed the module by composing its three boundaries in a single realistic Reservo flow: charge over HTTP against an http.server, persist in a SqliteBookingRepository over a file, and export to a CSV with tmp_path —the three real, verified in one green test, in half a second—. You justified each decision with lesson 7's rule: real at the three seams the test examines, doubled the clock and the email it doesn't examine, and all the real resources local and ephemeral so as not to inherit production's cost or risk. And you saw, with the start that comes back as text and the price_cents that comes back as an integer, the thread that runs through the whole module: the boundaries flatten everything to text, and the types are preserved only where you reconstruct them.

With this you master the discipline of testing at the boundaries: you know what a boundary is, how each of Reservo's three is exercised with the stdlib, how to do it fast and deterministic, and when to touch the real thing and when to double. You should be able to write a test that crosses a real boundary —or several— with criterion, and explain each choice.

But you noticed the loose seam: each boundary test created resources —a database, a server, a file— that had to be set up beforehand and cleaned afterward, by hand. When those resources have state that persists —a row one test leaves for the next, a server that doesn't shut down—, the tests start stepping on each other, depending on the order, failing intermittently. That's what module 7 solves: data and isolation in integration —rollback to leave the database as it was, fixtures that create and destroy real resources, and the discipline of keeping each test independent and repeatable even if it shares the real world—. You exercised the boundaries; now you're going to learn to isolate them.

Resources