Module 6: Real Boundaries Db Files Http

1. Module introduction: the system's boundaries

Description

In module 5 you wrote your first real integration and learned the rule that governs it: real BookingService and SqliteBookingRepository, collaborating, with the payment, the email, and the clock doubled. You plugged in SQLite, ran bookget, saw the green. It was an enormous leap —from certifying pieces separately to seeing them work together—, but SQLite came in almost on tiptoe, like just another collaborator. What you still didn't do was pause on what makes an integration, at its root, different from a unit test: the instant your code stops talking to itself and touches the outside world. That instant has rules of its own, and they're exactly where the bugs no double catches live.

Think about it. When BookingService calls a FakeBookingRepository, everything happens inside your Python process: one object talks to another object, in the same memory, under your rules. But when it calls the real SqliteBookingRepository, it crosses into a database engine that decides, with its rules, when a write is permanent and when it's discarded. When you export bookings to a file, you cross into the file system, which stores text on a disk that survives your program dying. And when the payment is charged over HTTP, you cross into a server on the other side of a TCP connection that can lag, fail, or not respond. Each of those contact points is a boundary: the edge where your code touches a resource you don't control. This module installs the three boundaries Reservo really crosses —database, files, HTTP—, each with the standard-library tool that exercises it without installing anything: sqlite3, pytest's tmp_path, and http.server.

Connection to the module: this lesson is the map of the territory. Here you meet the conceptual leap —from "integrating two components in-process" to "testing where the code touches the outside world"—, you see a first test crossing a real boundary green so you know where we're going, and you get the order of the eight lessons and the borders with what comes next. Lesson 2 nails the definition of "boundary"; lessons 3, 4, 5, and 6 go through each one —the SQLite transaction, :memory: versus file, the file with tmp_path, the HTTP with http.server—; lesson 7 distills the discipline of making them fast and deterministic and the rule of when to touch the real thing; lesson 8 crosses all three in a single flow. The hard border of the whole module: the data and isolation —using rollback to isolate tests, fixtures that create and destroy resources, keeping the tests repeatable— are module 7. Here we exercise each boundary; there we learn to isolate them.

Analogy: the doors of your house

Think of your house and everything that happens inside it. You move a chair from one room to another, put away a plate, turn off a light: they're actions that start and end under your roof, under your rules, without anyone from outside intervening. None of that crosses a door. But there are actions that do cross: dropping a letter in the corner mailbox, taking money out of the ATM, ordering delivery. The moment something crosses a door of your house, it enters a system you don't control —the postal service, the bank, the delivery person— with its own schedules, its own rules, and its own ways of failing. The letter can get lost; the ATM can be out of cash; the delivery person can take two hours or not show up. Inside the house, you're in charge; the moment you cross a door, you depend on someone else.

A unit test tests what happens inside the house: price_cents computes, book orchestrates with doubles, all under your rules and in your memory. A boundary test tests what happens when you cross a door: the booking that goes out to the database and is stored as a row of text that survives closing the program; the file you export that's still on the disk when you're gone; the charge that goes out over the network to a server that responds —or doesn't— in time. This module is learning to test the doors: verify that what crosses each one arrives correctly on the other side, with the rules of the system on the other side in place. And since each door leads to a different world —the database, the disk, the network—, each has its own technique. Reservo's three doors are this module's three boundaries.

Reservo's three boundaries

Before crossing them one by one, let's see them together. Reservo, with everything plugged into the real thing, touches the outside world at three points:

  • The database boundary. BookingService saves and reads bookings through the SqliteBookingRepository, which writes them as rows in a SQLite table. Crossing this boundary is turning a Python Booking object into a row of text and numbers, and deciding —with a transaction— when that row becomes permanent. The tool: the stdlib's sqlite3. It's lesson 3 (the transaction) and lesson 4 (:memory: versus file).
  • The file boundary. Reservo exports bookings to a file —for a backup, a report, a migration— and imports them back. Crossing this boundary is writing text to the disk and reading it back, with the same serialization as the database: everything becomes text, and the integers have to be reconstructed. The tool: pytest's tmp_path fixture, which gives a real temporary directory. It's lesson 5.
  • The HTTP boundary. Reservo's PaymentGateway lives behind an HTTP API: charging is a POST to a server. Crossing this boundary is sending a request over the network and waiting for a response that can lag. The tool: the stdlib's http.server, with which we spin up a fake gateway served over real HTTP. It's lesson 6.

Notice the pattern: each boundary is tested with a real piece on the other side —real SQLite, a real file, a real HTTP server—, but all from the standard library, without a single external dependency. All of Reservo runs on what Python ships with.

For this lesson's example we're going to debut the HTTP boundary, because it's the one that feels the most like "crossing". Here is the client that talks to the gateway over HTTP:

# reservo/http_gateway.py — an HTTP client for the PaymentGateway
import json
import urllib.request

from reservo.models import Receipt


class HttpPaymentGateway:
    """Talks to a PaymentGateway over HTTP. charge() makes a real POST."""

    def __init__(self, base_url, timeout=2.0):
        self._base_url = base_url.rstrip("/")
        self._timeout = timeout

    def charge(self, amount_cents):
        payload = json.dumps({"amount_cents": amount_cents}).encode("utf-8")
        request = urllib.request.Request(
            f"{self._base_url}/charge",
            data=payload,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with urllib.request.urlopen(request, timeout=self._timeout) as response:
            body = json.loads(response.read().decode("utf-8"))
        return Receipt(
            id=body["id"], ok=body["ok"], amount_cents=body["amount_cents"],
        )

It's urllib.request, which also ships with Python. charge builds a JSON body with the amount, makes a real POST to {base_url}/charge, reads the response, and turns it into a Receipt. There's no double here: if there's a server listening at base_url, the request goes out over TCP and comes back with whatever response the server gives. What we're missing is that server —and the point of the module is that we spin it up ourselves, minimal, with the stdlib—.

Worked example: a charge that crosses HTTP for real

To test the client we need something that responds on the other side. Instead of an in-memory double, we spin up a real HTTP server —a fake PaymentGateway served over HTTP— with http.server. It runs in a thread, on an ephemeral port (the operating system picks a free one), and responds to POST /charge with a receipt in JSON. The client talks to it over the network like it would talk to the production gateway.

# tests/test_http_boundary.py — the client against a real http.server
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer

import pytest

from reservo.http_gateway import HttpPaymentGateway


class FakeGatewayHandler(BaseHTTPRequestHandler):
    """A fake PaymentGateway, served over real HTTP."""

    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"],   # echo of the received amount
        }).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                                          # silences the server log


@pytest.fixture
def gateway_url():
    server = HTTPServer(("127.0.0.1", 0), FakeGatewayHandler)   # port 0 = ephemeral
    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_charge_makes_a_real_http_call(gateway_url):
    gateway = HttpPaymentGateway(gateway_url)
    receipt = gateway.charge(6000)                    # real POST, over TCP
    assert receipt.ok is True
    assert receipt.amount_cents == 6000               # the server received 6000
    assert receipt.id == "rcpt-http-1"

Don't worry about each line of the server yet —lesson 6 takes it apart calmly—. Keep the shape: there's a real server listening, the HttpPaymentGateway sends it a POST with 6000 cents, the server responds with a receipt, and the client turns it into a Receipt. The assertion receipt.amount_cents == 6000 proves something strong: that the amount left your process, traveled over the network to the server, and came back —the server returned it because it received it in the body—.

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

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

tests/test_http_boundary.py::test_charge_makes_a_real_http_call PASSED [100%]

============================== 1 passed in 0.01s ===============================

Green. And it's not a double: it's a real HTTP call, over TCP, to a server running in another thread. The charge crossed the network boundary and came back with its receipt. A StubPaymentGateway would have given you the same Receipt without leaving your memory; this test proves something the stub can't —that the client knows how to build the request, send it, read the response, and parse it, the whole HTTP protocol that in production is where it breaks—. That's the module's promise: test at the boundaries, where the integration bugs happen, with real stdlib resources. Everything that follows is doing it with each boundary and with criterion.

The module's map: the eight lessons

It's worth seeing the journey, because each lesson opens a boundary and lesson 7 joins them with a rule.

LessonTopicThe idea in one sentence
1The system's boundaries (this one)From "integrating in-process" to "testing where the code touches the outside world"
2What a boundary isThe point where your code touches a resource you don't control: disk, network, file system
3The database boundaryThe SQLite transaction: commit persists and makes visible, rollback discards
4:memory: versus fileThe two real SQLites: memory is instant and dies; the file persists and costs
5The file boundary with tmp_pathExport and import bookings to a real CSV file; everything becomes text
6The HTTP boundary with http.serverA fake gateway served over real HTTP; the client tests it over TCP
7Fast and deterministicMemory, tmp_path, thread with an ephemeral port, timeouts; and when real and when a double
8Mini-projectA flow that crosses the three real boundaries and verifies them green

If you understand what a boundary is, how each one is exercised, and how to do it without inheriting its slowness or its fragility, module 7 is learning to isolate those resources so the tests are independent and repeatable, and the guide's module 8 capstone brings it all together.

What this module does NOT cover (the border)

It's worth marking the limits from the start, because there are neighboring topics that look like they belong here.

The data and isolation are module 7. In this module you're going to create temporary databases, temporary files, and ephemeral servers, and clean them by hand just enough for each test to run. But the techniques for doing it well and systematically —using a transaction's rollback to leave the database as it was, fixtures that create and destroy the resource around each test, keeping the tests independent when they share real state— are module 7. Here we exercise each boundary; there we learn to isolate it without leaks or interference.

The web framework isn't this guide's. This module's HTTP boundary is a minimal stdlib http.server: a two-line server that responds to a POST, without routes, without middleware, without a framework. It serves to test the client HttpPaymentGateway against a real server. Testing a real web app —with FastAPI or Flask, its routes, its end-to-end request-response cycle, its dependencies— is testing-backend-applications-guide. Every time the temptation to "set up a complete API" appears, remember that's the other guide; here we spin up the minimum to exercise the boundary and move on.

The doubles aren't re-taught. That you double the clock, the email, or the payment around a boundary test is the test-doubles-and-test-data-guide's technique. Here we decide what to double and what to leave real (lesson 7), but the how to build a stub or a spy you already know.

Common mistakes

Believing "I used real SQLite in module 5" is the same as "I tested the database boundary". What happens: in module 5 you plugged in the SqliteBookingRepository and ran bookget green, and someone concludes they already master the boundary. Why it happens: using the real resource and studying its boundary feel similar. How to detect it: ask yourself whether you tested what a commit does, what a rollback does, whether an uncommitted write is visible from another connection. If not, you used SQLite as a collaborator but didn't exercise its boundary. How to fix it: the boundary has its own rules —the transaction is one of them— and this module tests them one by one. Using the resource is module 5; understanding and testing its boundary is this one.

Spinning up a complete API to test an HTTP client. What happens: to test HttpPaymentGateway, someone sets up a FastAPI with routes, validation, and a data model. Why it happens: "HTTP server" sounds like "web framework". How to detect it: if your test server has more than a screen of code, dependencies, or routes you don't use, you overdid it. How to fix it: to test a client, a minimal http.server that responds just enough is enough —a ten-line do_POST—. The complete web app is another guide; here the server is scaffolding, not the subject of the test.

Testing a boundary with the production resource. What happens: to "make it real", someone points the test at the staging database or the real payment gateway. Why it happens: more real seems more honest. How to detect it: if your test can charge a real card, leave data in a shared system, or fail because the office network is slow, you crossed into expensive and non-deterministic territory. How to fix it: the boundary is tested with a resource that's real but yours and ephemeral —SQLite in :memory: or in a temporary file, a local http.server on an ephemeral port—. It's real (it exercises the protocol, the serialization, the transaction) without being production. That balance is lesson 7.

Exercises

Exercise 1 — Boundary or inside the house? For each action of a Reservo test, say whether it crosses a boundary (touches an external resource: disk, network, database) or happens inside the process (only Python objects): (a) price_cents(FOCUS, ANA, 3); (b) SqliteBookingRepository(sqlite3.connect(":memory:")).save(booking); (c) FakeBookingRepository().save(booking); (d) HttpPaymentGateway(url).charge(6000) against an http.server; (e) export_bookings(bookings, tmp_path / "b.csv").

See solution
  • (a) Inside the house. price_cents is pure arithmetic: it takes data, returns an integer, without touching anything external. It doesn't even cross a seam; it's domain logic.
  • (b) Boundary (database). save writes to SQLite, a database engine that serializes the object into a row and decides, with a transaction, when it's permanent. Even in :memory:, it's real SQLite with its rules —it crosses the database boundary—.
  • (c) Inside the house. The FakeBookingRepository stores the object in a dict in your process. Nothing leaves Python's memory; there's no external resource. It's a double, not a boundary.
  • (d) Boundary (network). charge sends a POST over TCP to a server —even though the server runs in another thread of your same machine, the request crosses the network stack—. It's the HTTP boundary.
  • (e) Boundary (file system). export_bookings writes text to a file on the disk, which survives the process. It crosses the file boundary, even though the file is in a temporary directory.

The rule you're sharpening: a boundary is any point where your code touches something that doesn't live in your Python memory —the disk, the network, a database engine—. A double, by definition, stays inside the house; that's why it never exercises a boundary.

Exercise 2 — What the HTTP test proves that a stub can't. The worked example uses a real http.server instead of a StubPaymentGateway that returns a fixed Receipt. List at least three concrete things the real-server version tests that the stub leaves untested.

See solution

With a real http.server, the test exercises the whole HTTP protocol of the client; a stub that returns a fixed Receipt skips that entire protocol. Three concrete things only the real version tests:

  1. That the client builds the request correctly. HttpPaymentGateway.charge serializes {"amount_cents": 6000} to JSON, puts it in the body, sets the POST method and the URL /charge. If any of that is wrong —the wrong method, the URL without the slash, malformed JSON—, the real server rejects it or responds differently. The stub never sees the request, so it can't give away an error in building it.
  2. That the client reads and parses the response correctly. The server returns JSON with id, ok, and amount_cents; the client does json.loads and builds a Receipt. If the client expected another field name, or didn't know how to decode the body, it would fail against the real server. The stub delivers an already-made Receipt, without going through the parsing.
  3. That the round-trip actually happens over the network. That the amount_cents comes back as 6000 proves that the amount traveled in the body, reached the server, and returned —a complete circuit—. The stub sends nothing; its 6000 never leaves your memory.

(Besides, the real version lets you test the timeout, an ok: false, a 500 from the server —paths the stub, which always returns the same, doesn't touch—.) The moral: the stub proves that your business logic reacts correctly to a receipt; the real server proves that your HTTP client speaks the protocol correctly. They're two different things, and the integration bugs live in the second.

Exercise 3 — Name a flow's boundaries. The flow of canceling a booking in Reservo, with everything real, does: reads the booking from the SqliteBookingRepository, computes the refund, refunds via the HttpPaymentGateway, saves the cancelled status, and writes a line to an audit file. List each boundary it crosses and with what stdlib tool you'd test it.

See solution

The flow crosses three boundaries (two of them twice):

  • Read the booking from the repository → database boundary. repo.get(booking_id) queries SQLite. It's tested with sqlite3 (lessons 3 and 4), in :memory: or in a temporary file.
  • Refund via the gateway → HTTP boundary. payments.refund(...) would make a POST to the payment server. It's tested with a stdlib http.server spun up in a thread with an ephemeral port (lesson 6).
  • Save the cancelled status → database boundary again. repo.save(booking) writes to SQLite again, with its transaction and its commit.
  • Write the audit line → file boundary. Opening a file and appending a line touches the file system. It's tested with pytest's tmp_path (lesson 5).

The refund computation (refund_cents), on the other hand, happens inside the house: it's domain arithmetic, without an external resource, and is tested with a unit test. Naming a flow's boundaries is the first step to deciding what to test at each one and what to double around it —exactly what lesson 7 formalizes as a rule—.

Summary and next step

In this lesson you took the leap that defines the module: from integrating two components in-process to testing where your code touches the outside world. With the doors of your house you saw that there are actions that happen under your rules and actions that cross into systems you don't control —the postal service, the bank, the network—, and that testing the latter needs its own techniques. You met Reservo's three boundaries —database, files, HTTP— and each one's stdlib tool. And you saw, with pytest output, a test crossing a real boundary green: the HttpPaymentGateway charging 6000 cents against a real http.server, with the whole HTTP protocol exercised, something no in-memory stub tests.

Before moving on you should be able to: distinguish an action that crosses a boundary (touches disk, network, or database) from one that happens inside the process; name Reservo's three boundaries and their stdlib tool; and explain what a test against a real resource proves that a double leaves untested.

What comes next is nailing the definition until no ambiguity remains. In lesson 2 we're going to say precisely what a boundary is —and isn't—: why an in-process seam (the repository's interface) isn't the same as the resource's boundary (SQLite's engine), and why the boundary is at once where doubling tempts most and where the integration bugs hide most. With that distinction sharpened, each of the four boundaries that follow falls into place.

Resources