Módulo 6: Fronteras reales: DB, archivos, HTTP

8. Mini-proyecto: las tres fronteras de Reservo

Descripción

Llegó el cierre del módulo, y con él la síntesis práctica. Recorriste las tres fronteras de Reservo por separado —la base de datos con su transacción, el archivo con tmp_path, el HTTP con http.server— y en la lección 7 destilaste la regla que gobierna cuándo tocar cada una de verdad. Ahora vas a juntarlas en un solo flujo, el más realista que Reservo produce: un socio reserva, el cobro sale por HTTP hacia el gateway, la reserva se persiste en SQLite, y después alguien exporta las reservas de esa sala a un archivo para un reporte. Tres fronteras reales, cruzadas en secuencia, verificadas en una prueba. Es el examen final del módulo: si puedes escribir esta prueba y explicar qué dejaste real y qué doblaste en cada frontera, dominas la disciplina.

Este mini-proyecto no introduce nada nuevo; compone lo que ya sabes. El HttpPaymentGateway contra un http.server de la lección 6; el SqliteBookingRepository en un archivo temporal con la persistencia de las lecciones 3 y 4; la exportación a CSV con tmp_path de la lección 5; y la regla de la lección 7 aplicada tres veces —en cada frontera decides qué va real y qué va doblado—. El valor de juntarlas es ver que las tres conviven en un flujo sin estorbarse, y sentir cómo una prueba de integración amplia puede cruzar varias fronteras reales a la vez y seguir siendo rápida y determinista si respetas las tres claves. Al final tendrás una prueba que, en medio segundo, ejerce el protocolo HTTP, una transacción de base de datos con persistencia en disco, y la serialización a un archivo —todo lo que este módulo enseñó, en verde—.

Conexión con el módulo: esta lección es el capstone del módulo 6. Reúne las tres fronteras y la regla de decisión en un entregable único, y te deja en la puerta del módulo 7. Porque al escribir esta prueba vas a notar algo incómodo: creaste una base de datos en un archivo, levantaste un servidor, escribiste un CSV —y todo eso hay que montarlo antes y limpiarlo después—. En este módulo lo hicimos a mano, con tmp_path y un fixture de servidor, lo justo para que corra. Cómo hacer ese montaje y esa limpieza sistemáticos y a prueba de fugas —fixtures de recursos, aislamiento con rollback, mantener cada prueba independiente aunque comparta estado real— es exactamente el módulo 7. Este mini-proyecto es la última prueba con el aislamiento hecho a mano; el siguiente módulo lo industrializa.

El reto

Escribe una prueba de integración que cruce las tres fronteras reales de Reservo en un flujo, y entrégala con su salida de pytest en verde y la justificación de tus decisiones. En concreto, la prueba debe:

  1. Frontera HTTP (real). Cobrar a través de un HttpPaymentGateway real que hace un POST a un http.server de mentira levantado en un hilo con puerto efímero.
  2. Frontera de base de datos (real). Persistir la reserva en un SqliteBookingRepository sobre un archivo (para probar la persistencia en disco), y comprobar que sobrevive leyéndola con una conexión nueva.
  3. Frontera de archivos (real). Exportar las reservas de la sala a un CSV con tmp_path, e importarlas de vuelta, verificando el ida-y-vuelta.

Y debe respetar la regla de la lección 7: real en las tres fronteras que examina (son el sujeto de esta prueba amplia) y doblado en lo que no examina —el reloj (FixedClock), el correo (SpyEmailSender)—. Antes de mirar la solución, intenta escribirla tú: monta el servidor con un fixture, usa tmp_path para el archivo de base de datos y para el CSV, y encadena cobrar → persistir → reabrir → exportar → importar.

La solución

Aquí está la prueba completa. Léela por bloques —el servidor y su fixture arriba (idénticos a los de la lección 6), y luego el flujo que cruza las tres fronteras—.

# tests/test_three_boundaries.py — las tres fronteras reales de Reservo en un flujo
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"

    # --- FRONTERA HTTP + FRONTERA DB: cobrar por HTTP, persistir en SQLite ---
    service = BookingService(
        Calendar(), FixedClock(CLOCK),
        HttpPaymentGateway(gateway_url),                    # el cobro cruza HTTP real
        SpyEmailSender(),
        SqliteBookingRepository(sqlite3.connect(db_path)),  # persiste en un archivo
    )
    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))

    # La reserva persistio: se lee con una conexion NUEVA al mismo archivo.
    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}

    # --- FRONTERA DE ARCHIVOS: exportar a CSV y volver a leer ---
    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)

Sigue el flujo, que es el de un día en Reservo. Se monta BookingService con dos piezas reales de frontera —el HttpPaymentGateway (que cobrará por HTTP) y el SqliteBookingRepository sobre un archivo (que persistirá en disco)— y dos dobles —el reloj y el correo, que no son fronteras que examinemos—. Se hacen dos reservas: en cada una, el cobro cruza la frontera HTTP hacia el servidor, y la reserva cruza la frontera de la base de datos hacia el archivo. Luego se abre una conexión nueva al mismo archivo (reopened) y se lee: si la reserva está ahí, es porque el commit de save la persistió en disco de verdad —la prueba de las lecciones 3 y 4—. Por último se exporta la agenda de Focus a un CSV con tmp_path y se importa de vuelta: el ida-y-vuelta de la frontera de archivos de la lección 5, con el price_cents que vuelve como entero porque import_bookings lo reconvierte. Tres fronteras, un flujo.

Qué esperar. En mi máquina (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 ===============================

Verde, en medio segundo. Ese único verde certifica mucho: que el cobro se serializó a JSON, viajó por TCP a un servidor de verdad y volvió con su recibo; que la reserva se serializó a una fila, se escribió en un archivo de SQLite, se confirmó con un commit, y sobrevivió a que abriéramos una conexión nueva; y que las reservas se exportaron a texto en un CSV del disco y se reimportaron con sus tipos reconstruidos. Tres fronteras reales, ejercidas de punta a punta, sin un solo doble en ninguna de las tres costuras bajo examen —y aun así rápido y determinista, porque cada recurso es local y efímero: :memory: no, pero un archivo en tmp_path que se borra solo, y un servidor en un hilo con puerto efímero—.

Por qué quedó real lo que quedó real (y doblado lo demás)

El entregable pide justificar las decisiones, así que hagámoslas explícitas frontera por frontera —es la regla de la lección 7 aplicada tres veces—:

  • La frontera HTTP quedó real (un http.server de mentira, pero HTTP de verdad) porque una de las cosas que esta prueba examina es que el cobro cruza bien la red: que el cliente arma la petición, la manda y parsea la respuesta. Doblarla con un StubPaymentGateway habría saltado justo ese protocolo. Es real, pero local y efímero —el servidor lo levantamos nosotros—, no el gateway de producción que cobraría dinero.
  • La frontera de base de datos quedó real y sobre un archivo (no :memory:) porque la prueba examina la persistencia en disco: que la reserva sobreviva a abrir una conexión nueva. Eso :memory: no lo puede probar (una base :memory: reabierta es nueva y vacía), así que aquí el archivo con tmp_path es la elección correcta —y tmp_path lo borra solo—.
  • La frontera de archivos quedó real (un CSV en tmp_path) porque la prueba examina la exportación/importación: la serialización a texto y la reconstrucción de tipos. Doblarla no tendría sentido —es una de las tres costuras bajo examen—.
  • El reloj y el correo quedaron doblados (FixedClock, SpyEmailSender) porque no son fronteras que esta prueba examine. El reloj real introduciría no-determinismo (la hora cambia); el correo real tendría un efecto externo (mandar mensajes) sin aportar nada a lo que probamos. Son colaboradores de paso: penumbra, no foco.

Esta prueba es más amplia que las de la lección 7 —ilumina tres fronteras a la vez, no una— y eso es legítimo: a veces quieres verificar un flujo realista completo. La clave es que sigue respetando la regla: real en las costuras que examina, doblado en las que no, y todos los recursos reales son locales y efímeros para no heredar el costo ni el riesgo de producción. Es una integración amplia, no un end-to-end contra sistemas reales.

Errores comunes

Usar :memory: en la prueba que examina la persistencia en disco. Qué pasa: alguien arma el flujo con sqlite3.connect(":memory:") y la aserción de "reabrir con una conexión nueva" pasa por accidente o falla de forma confusa. Por qué pasa: :memory: es el hábito. Cómo detectarlo: si el test dice probar que la reserva sobrevive a reabrir, pero usa :memory:, no lo prueba —una conexión nueva a :memory: es una base vacía, y get lanzaría KeyError—. Cómo corregirlo: cuando la persistencia en disco es parte del examen, la base va en un archivo (tmp_path / "reservo.db"), como en la solución. :memory: es para cuando no pruebas la durabilidad.

Dejar real el gateway de producción "para que sea más realista". Qué pasa: alguien cambia el http.server de mentira por el gateway de pagos real de staging. Por qué pasa: la tentación de "más real". Cómo detectarlo: si tu prueba puede cobrar dinero, depende de un sistema externo vivo, o tarda por la red real, cruzaste de una integración amplia a un end-to-end frágil. Cómo corregirlo: la frontera HTTP se examina con un servidor local y de mentira que ejerce el protocolo sin los efectos ni la dependencia de producción. Real en el protocolo, tuyo en el control.

Olvidar montar o limpiar un recurso. Qué pasa: alguien levanta el servidor pero no lo apaga, o escribe el CSV en una ruta fija que no se borra; la suite deja hilos vivos, puertos ocupados o archivos sueltos. Por qué pasa: con tres recursos en juego, es fácil que uno se escape. Cómo detectarlo: corridas que cuelgan al final, puertos ocupados, o archivos que aparecen en el proyecto. Cómo corregirlo: cada recurso real va con su montaje y su limpieza —el servidor en un fixture con try/finally, la base y el CSV en tmp_path que se borra solo—. Que este montaje-y-limpieza sea aún manual es precisamente lo que el módulo 7 convierte en una disciplina sistemática.

Ejercicios

Ejercicio 1 — Agrega la cuarta verificación. La prueba comprueba que el price_cents sobrevive las tres fronteras como entero. Agrega una aserción que verifique qué le pasó al start de una reserva importada del CSV, y explica por qué ese resultado es coherente con todo el módulo.

Ver solución

La aserción sería que start volvió como texto, no como 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"

Es coherente con todo el módulo porque start cruzó dos fronteras que lo serializan a texto y ninguna que lo reconstruya. Primero, la base de datos: SqliteBookingRepository.save lo guardó con .isoformat() y get lo devolvió como str (el bug del datetime del módulo 1). Luego, el archivo: export_bookings lo escribió como texto y import_bookings lo dejó como str (start=r["start"], sin datetime.fromisoformat). En ninguno de los dos pasos alguien lo reconvirtió a datetime. El price_cents, en cambio, volvió como int porque import_bookings lo reenvuelve con int(...) —y en SQLite ya era INTEGER nativo—.

La lección de fondo, que es el hilo del módulo entero: las fronteras aplanan todo a texto; los tipos se conservan solo donde tu código los reconstruye explícitamente. Un valor que cruza varias fronteras sin reconstruirse acumula "texto sobre texto" y termina lejos de su tipo original. Por eso, si Reservo necesitara start como datetime después de importar, habría que reconstruirlo en el importador —el mismo arreglo, en cada frontera que lo aplana—.

Ejercicio 2 — Divide el capstone en pruebas enfocadas. La prueba del mini-proyecto ilumina las tres fronteras a la vez, lo cual es legítimo para un flujo realista. Pero para el diagnóstico, a veces conviene una prueba por frontera. Describe las tres pruebas enfocadas en que dividirías este flujo, diciendo qué queda real y qué doblado en cada una.

Ver solución

Tres pruebas enfocadas, cada una con el foco en una frontera:

  1. Prueba de la frontera HTTP. Real: el HttpPaymentGateway contra un http.server de mentira. Doblados: el repositorio (FakeBookingRepository), el reloj, el correo. Verifica que book cobra el monto correcto cruzando la red. (Es test_http_boundary_real_repo_doubled de la lección 7.)
  2. Prueba de la frontera de base de datos. Real: el SqliteBookingRepository sobre un archivo (tmp_path), con la verificación de reabrir con una conexión nueva. Doblados: el gateway (StubPaymentGateway), el reloj, el correo. Verifica que la reserva persiste en disco.
  3. Prueba de la frontera de archivos. Real: la exportación/importación a un CSV con tmp_path. Los datos pueden venir de un FakeBookingRepository (no es la frontera bajo examen). Verifica el ida-y-vuelta y la reconstrucción de tipos.

Cada una es rápida, determinista, y cuando falla señala una sola frontera —el diagnóstico es inmediato—. La prueba amplia del mini-proyecto tiene su lugar (verificar que el flujo realista completo funciona), pero cuando algo se rompe, las tres enfocadas te dicen dónde. Un equipo maduro suele tener ambas: varias pruebas enfocadas por frontera (la mayoría) y unas pocas amplias que verifican flujos completos. Es la pirámide aplicada dentro de la integración: muchas estrechas y baratas, pocas amplias y valiosas.

Ejercicio 3 — El recurso que se te escapó. Imagina que quitas el bloque try/finally del fixture gateway_url y solo dejas el arranque del servidor. La prueba sigue en verde la primera vez. Explica qué problema aparece al correr la suite completa varias veces, y por qué conecta con el módulo 7.

Ver solución

Sin el try/finally, el servidor nunca se apaga: cada vez que el fixture corre, levanta un HTTPServer y un hilo que se quedan vivos —el bucle serve_forever sigue corriendo, el puerto queda ocupado, el hilo daemon no se une—. La primera prueba pasa porque el servidor funciona; el problema es acumulativo. Al correr la suite completa (o varias veces), se van apilando servidores y hilos sin cerrar: puertos ocupados que, aunque sean efímeros, no se liberan; hilos que consumen recursos; y, según el sistema, advertencias sobre recursos no cerrados o corridas que tardan en terminar limpio. Es una fuga de recursos: el estado real que creaste (un servidor) no se limpió, y se filtra de un test al entorno de los siguientes.

Conecta directamente con el módulo 7 porque ese es su tema central: cómo montar y desmontar recursos reales de forma sistemática y sin fugas. El try/finally (o el yield con su bloque de limpieza) es la versión manual de esa disciplina —creas el recurso, lo entregas, lo destruyes pase lo que pase—. El módulo 7 la generaliza a fixtures de recursos (una base de datos temporal que se crea y se destruye alrededor de cada test), al rollback como técnica para dejar la base como estaba, y al principio de que cada prueba de integración debe ser independiente y repetible aunque toque estado real. Este mini-proyecto es la última vez que montamos y limpiamos a mano; el olvido de este ejercicio es exactamente la clase de fuga que el aislamiento del módulo 7 elimina de raíz.

Resumen y siguiente paso

En este mini-proyecto cerraste el módulo componiendo sus tres fronteras en un solo flujo realista de Reservo: cobrar por HTTP contra un http.server, persistir en un SqliteBookingRepository sobre un archivo, y exportar a un CSV con tmp_path —las tres reales, verificadas en una prueba en verde, en medio segundo—. Justificaste cada decisión con la regla de la lección 7: real en las tres costuras que la prueba examina, doblado el reloj y el correo que no examina, y todos los recursos reales locales y efímeros para no heredar el costo ni el riesgo de producción. Y viste, con el start que vuelve como texto y el price_cents que vuelve como entero, el hilo que atraviesa el módulo entero: las fronteras aplanan todo a texto, y los tipos se conservan solo donde los reconstruyes.

Con esto dominas la disciplina de probar en las fronteras: sabes qué es una frontera, cómo se ejerce cada una de las tres de Reservo con la stdlib, cómo hacerlo rápido y determinista, y cuándo tocar lo real y cuándo doblar. Deberías poder escribir una prueba que cruce una frontera real —o varias— con criterio, y explicar cada elección.

Pero notaste la costura suelta: cada prueba de frontera creó recursos —una base de datos, un servidor, un archivo— que hubo que montar antes y limpiar después, a mano. Cuando esos recursos tienen estado que persiste —una fila que un test deja para el siguiente, un servidor que no se apaga—, las pruebas empiezan a pisarse, a depender del orden, a fallar de forma intermitente. Eso es lo que el módulo 7 resuelve: los datos y el aislamiento en integración —el rollback para dejar la base como estaba, fixtures que crean y destruyen recursos reales, y la disciplina de mantener cada prueba independiente y repetible aunque comparta el mundo real—. Ejerciste las fronteras; ahora vas a aprender a aislarlas.

Recursos