Módulo 6: Fronteras reales: DB, archivos, HTTP
1. Presentación del módulo: las fronteras del sistema
Descripción
En el módulo 5 escribiste tu primera integración de verdad y aprendiste la regla que la gobierna: BookingService y el SqliteBookingRepository reales, colaborando, con el pago, el correo y el reloj doblados. Enchufaste SQLite, corriste book→get, viste el verde. Fue un salto enorme —de certificar piezas por separado a verlas trabajar juntas—, pero SQLite entró casi de puntillas, como un colaborador más. Lo que todavía no hiciste fue detenerte en lo que hace que una integración sea, de raíz, distinta de un unit test: el instante en que tu código deja de hablar consigo mismo y toca el mundo exterior. Ese instante tiene reglas propias, y son justo donde viven los bugs que ningún doble atrapa.
Piénsalo. Cuando BookingService llama a un FakeBookingRepository, todo pasa dentro de tu proceso de Python: un objeto le habla a otro objeto, en la misma memoria, bajo tus reglas. Pero cuando llama al SqliteBookingRepository real, cruza hacia un motor de base de datos que decide, con sus reglas, cuándo una escritura es permanente y cuándo se descarta. Cuando exportas reservas a un archivo, cruzas hacia el sistema de archivos, que guarda texto en un disco que sobrevive a que tu programa muera. Y cuando el pago se cobra por HTTP, cruzas hacia un servidor al otro lado de una conexión TCP que puede tardar, fallar o no responder. Cada uno de esos puntos de contacto es una frontera: el borde donde tu código toca un recurso que no controlas. Este módulo instala las tres fronteras que Reservo cruza de verdad —base de datos, archivos, HTTP—, cada una con la herramienta de la biblioteca estándar que la ejerce sin instalar nada: sqlite3, tmp_path de pytest, y http.server.
Conexión con el módulo: esta lección es el mapa del territorio. Aquí conoces el salto conceptual —de "integrar dos componentes en proceso" a "probar donde el código toca el mundo exterior"—, ves un primer test cruzando una frontera real en verde para saber hacia dónde vamos, y recibes el orden de las ocho lecciones y las fronteras con lo que sigue. La lección 2 clava la definición de "frontera"; la 3, 4, 5 y 6 recorren cada una —la transacción de SQLite, :memory: contra archivo, el archivo con tmp_path, el HTTP con http.server—; la 7 destila la disciplina de hacerlas rápidas y deterministas y la regla de cuándo tocar lo real; la 8 las cruza las tres en un solo flujo. La frontera dura de todo el módulo: los datos y el aislamiento —usar el rollback para aislar tests, fixtures que crean y destruyen recursos, mantener las pruebas repetibles— son el módulo 7. Aquí ejercemos cada frontera; allá aprendemos a aislarlas.
Analogía: las puertas de tu casa
Piensa en tu casa y en todo lo que ocurre dentro de ella. Mueves una silla de un cuarto a otro, guardas un plato, apagas una luz: son acciones que empiezan y terminan bajo tu techo, bajo tus reglas, sin que nadie de afuera intervenga. Nada de eso cruza una puerta. Pero hay acciones que sí cruzan: echar una carta al buzón de la esquina, sacar dinero del cajero, pedir algo a domicilio. En el momento en que algo cruza una puerta de tu casa, entra en un sistema que no controlas —el correo, el banco, el repartidor— con sus propios horarios, sus propias reglas y sus propias formas de fallar. La carta puede perderse; el cajero puede estar sin efectivo; el repartidor puede tardar dos horas o no llegar. Dentro de casa, tú mandas; en cuanto cruzas una puerta, dependes de otro.
Un unit test prueba lo que pasa dentro de casa: price_cents calcula, book orquesta con dobles, todo bajo tus reglas y en tu memoria. Una prueba de frontera prueba lo que pasa al cruzar una puerta: la reserva que sale hacia la base de datos y se guarda como una fila de texto que sobrevive a cerrar el programa; el archivo que exportas y que sigue en el disco cuando ya no estás; el cobro que sale por la red hacia un servidor que responde —o no— a tiempo. Este módulo es aprender a probar las puertas: verificar que lo que cruza cada una llega bien al otro lado, con las reglas del sistema que hay del otro lado puestas. Y como cada puerta da a un mundo distinto —la base de datos, el disco, la red—, cada una tiene su técnica. Las tres puertas de Reservo son las tres fronteras de este módulo.
Las tres fronteras de Reservo
Antes de cruzarlas una por una, veámoslas juntas. Reservo, con todo enchufado a lo real, toca el mundo exterior en tres puntos:
- La frontera de base de datos.
BookingServiceguarda y lee reservas a través delSqliteBookingRepository, que las escribe como filas en una tabla de SQLite. Cruzar esta frontera es convertir un objetoBookingde Python en una fila de texto y números, y decidir —con una transacción— cuándo esa fila se vuelve permanente. La herramienta:sqlite3de la stdlib. Es la lección 3 (la transacción) y la 4 (:memory:contra archivo). - La frontera de archivos. Reservo exporta reservas a un archivo —para un respaldo, un reporte, una migración— y las vuelve a importar. Cruzar esta frontera es escribir texto en el disco y leerlo de vuelta, con la misma serialización que la base de datos: todo se vuelve texto, y los enteros hay que reconstruirlos. La herramienta: el fixture
tmp_pathde pytest, que da un directorio temporal de verdad. Es la lección 5. - La frontera HTTP. El
PaymentGatewayde Reservo vive detrás de una API HTTP: cobrar es unPOSTa un servidor. Cruzar esta frontera es mandar una petición por la red y esperar una respuesta que puede tardar. La herramienta:http.serverde la stdlib, con el que levantamos un gateway de mentira servido por HTTP de verdad. Es la lección 6.
Fíjate en el patrón: cada frontera se prueba con una pieza real del otro lado —SQLite de verdad, un archivo de verdad, un servidor HTTP de verdad—, pero toda de la biblioteca estándar, sin una sola dependencia externa. Reservo entero corre con lo que trae Python.
Para el ejemplo de esta lección vamos a estrenar la frontera HTTP, porque es la que más "cruce" se siente. Aquí está el cliente que habla con el gateway por HTTP:
# reservo/http_gateway.py — un cliente HTTP del PaymentGateway
import json
import urllib.request
from reservo.models import Receipt
class HttpPaymentGateway:
"""Habla con un PaymentGateway por HTTP. charge() hace un POST real."""
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"],
)
Es urllib.request, que también viene con Python. charge arma un cuerpo JSON con el monto, hace un POST de verdad a {base_url}/charge, lee la respuesta y la convierte en un Receipt. No hay ningún doble aquí: si hay un servidor escuchando en base_url, la petición sale por TCP y vuelve con la respuesta que el servidor dé. Lo que nos falta es ese servidor —y la gracia del módulo es que lo levantamos nosotros, mínimo, con la stdlib—.
Ejemplo trabajado: un cobro que cruza HTTP de verdad
Para probar el cliente necesitamos algo que responda al otro lado. En vez de un doble en memoria, levantamos un servidor HTTP real —un PaymentGateway de mentira servido por HTTP— con http.server. Corre en un hilo, en un puerto efímero (el sistema operativo elige uno libre), y responde al POST /charge con un recibo en JSON. El cliente le habla por la red como le hablaría al gateway de producción.
# tests/test_http_boundary.py — el cliente contra un http.server de verdad
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from reservo.http_gateway import HttpPaymentGateway
class FakeGatewayHandler(BaseHTTPRequestHandler):
"""Un PaymentGateway de mentira, servido por HTTP de verdad."""
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"], # eco del monto recibido
}).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 # silencia el log del server
@pytest.fixture
def gateway_url():
server = HTTPServer(("127.0.0.1", 0), FakeGatewayHandler) # puerto 0 = efimero
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) # POST real, sobre TCP
assert receipt.ok is True
assert receipt.amount_cents == 6000 # el server recibio 6000
assert receipt.id == "rcpt-http-1"
No te preocupes por cada línea del servidor todavía —la lección 6 lo desarma con calma—. Quédate con la forma: hay un servidor de verdad escuchando, el HttpPaymentGateway le manda un POST con 6000 centavos, el servidor responde con un recibo, y el cliente lo convierte en un Receipt. La aserción receipt.amount_cents == 6000 prueba algo fuerte: que el monto salió de tu proceso, viajó por la red al servidor, y volvió —el servidor lo devolvió porque lo recibió en el cuerpo—.
Qué esperar. En mi máquina (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 ===============================
Verde. Y no es un doble: es una llamada HTTP de verdad, sobre TCP, a un servidor que corre en otro hilo. El cobro cruzó la frontera de la red y volvió con su recibo. Un StubPaymentGateway te habría dado el mismo Receipt sin salir de tu memoria; este test prueba algo que el stub no puede probar —que el cliente sabe armar la petición, mandarla, leer la respuesta y parsearla, todo el protocolo HTTP que en producción es donde se rompe—. Esa es la promesa del módulo: probar en las fronteras, donde ocurren los bugs de integración, con recursos reales de la stdlib. Todo lo que sigue es hacerlo con cada frontera y con criterio.
El mapa del módulo: las ocho lecciones
Vale la pena ver el recorrido, porque cada lección abre una frontera y la 7 las une con una regla.
| Lección | Tema | La idea en una frase |
|---|---|---|
| 1 | Las fronteras del sistema (esta) | De "integrar en proceso" a "probar donde el código toca el mundo exterior" |
| 2 | Qué es una frontera | El punto donde tu código toca un recurso que no controlas: disco, red, sistema de archivos |
| 3 | La frontera de base de datos | La transacción de SQLite: commit persiste y hace visible, rollback descarta |
| 4 | :memory: contra archivo | Los dos SQLite reales: la memoria es instantánea y muere; el archivo persiste y cuesta |
| 5 | La frontera de archivos con tmp_path | Exportar e importar reservas a un archivo CSV real; todo se vuelve texto |
| 6 | La frontera HTTP con http.server | Un gateway de mentira servido por HTTP de verdad; el cliente lo prueba sobre TCP |
| 7 | Rápido y determinista | Memoria, tmp_path, hilo con puerto efímero, timeouts; y cuándo real y cuándo doble |
| 8 | Mini-proyecto | Un flujo que cruza las tres fronteras reales y las verifica en verde |
Si entiendes qué es una frontera, cómo se ejerce cada una y cómo hacerlo sin heredar su lentitud ni su fragilidad, el módulo 7 es aprender a aislar esos recursos para que las pruebas sean independientes y repetibles, y el capstone del módulo 8 de la guía junta todo.
Lo que este módulo NO toca (la frontera)
Conviene marcar los límites desde ahora, porque hay temas vecinos que parecen de aquí.
Los datos y el aislamiento son el módulo 7. En este módulo vas a crear bases de datos temporales, archivos temporales y servidores efímeros, y a limpiarlos a mano lo justo para que cada prueba corra. Pero las técnicas para hacerlo bien y sistemáticamente —usar el rollback de una transacción para dejar la base como estaba, fixtures que crean y destruyen el recurso alrededor de cada test, mantener las pruebas independientes cuando comparten estado real— son el módulo 7. Aquí ejercemos cada frontera; allá aprendemos a aislarla sin fugas ni interferencias.
El framework web no es de esta guía. La frontera HTTP de este módulo es un http.server mínimo de la stdlib: un servidor de dos líneas que responde a un POST, sin rutas, sin middleware, sin un framework. Sirve para probar el cliente HttpPaymentGateway contra un servidor real. Probar una app web de verdad —con FastAPI o Flask, sus rutas, su ciclo petición-respuesta de punta a punta, sus dependencias— es testing-backend-applications-guide. Cada vez que la tentación de "montar una API completa" aparezca, recuerda que esa es la otra guía; aquí levantamos lo mínimo para ejercer la frontera y seguimos.
Los dobles no se re-enseñan. Que dobles el reloj, el correo o el pago alrededor de una prueba de frontera es la técnica de test-doubles-and-test-data-guide. Aquí decidimos qué doblar y qué dejar real (lección 7), pero el cómo construir un stub o un spy ya lo sabes.
Errores comunes
Creer que "usé SQLite real en el módulo 5" es lo mismo que "probé la frontera de la base de datos". Qué pasa: en el módulo 5 enchufaste el SqliteBookingRepository y corriste book→get en verde, y alguien concluye que ya domina la frontera. Por qué pasa: usar el recurso real y estudiar su frontera se sienten parecidos. Cómo detectarlo: pregúntate si probaste qué hace un commit, qué hace un rollback, si una escritura sin confirmar se ve desde otra conexión. Si no, usaste SQLite como colaborador pero no ejerciste su frontera. Cómo corregirlo: la frontera tiene reglas propias —la transacción es una de ellas— y este módulo las prueba una por una. Usar el recurso es el módulo 5; entender y probar su frontera es este.
Levantar una API completa para probar un cliente HTTP. Qué pasa: para probar HttpPaymentGateway, alguien monta un FastAPI con rutas, validación y un modelo de datos. Por qué pasa: "servidor HTTP" suena a "framework web". Cómo detectarlo: si tu servidor de prueba tiene más de una pantalla de código, dependencias o rutas que no usas, te pasaste. Cómo corregirlo: para probar un cliente basta un http.server mínimo que responda lo justo —un do_POST de diez líneas—. La app web completa es otra guía; aquí el servidor es un andamio, no el sujeto de la prueba.
Probar una frontera con el recurso de producción. Qué pasa: para "que sea de verdad", alguien apunta el test a la base de datos de staging o al gateway de pagos real. Por qué pasa: más real parece más honesto. Cómo detectarlo: si tu test puede cobrar una tarjeta de verdad, dejar datos en un sistema compartido o fallar porque la red del trabajo está lenta, cruzaste a un territorio caro y no determinista. Cómo corregirlo: la frontera se prueba con un recurso real pero tuyo y efímero —SQLite en :memory: o en un archivo temporal, un http.server local en un puerto efímero—. Es real (ejerce el protocolo, la serialización, la transacción) sin ser producción. Ese equilibrio es la lección 7.
Ejercicios
Ejercicio 1 — ¿Frontera o dentro de casa? Para cada acción de un test de Reservo, di si cruza una frontera (toca un recurso externo: disco, red, base de datos) o pasa dentro del proceso (solo objetos de Python): (a) price_cents(FOCUS, ANA, 3); (b) SqliteBookingRepository(sqlite3.connect(":memory:")).save(booking); (c) FakeBookingRepository().save(booking); (d) HttpPaymentGateway(url).charge(6000) contra un http.server; (e) export_bookings(bookings, tmp_path / "b.csv").
Ver solución
- (a) Dentro de casa.
price_centses aritmética pura: recibe datos, devuelve un entero, sin tocar nada externo. Ni siquiera cruza una costura; es lógica de dominio. - (b) Frontera (base de datos).
saveescribe en SQLite, un motor de base de datos que serializa el objeto a una fila y decide, con una transacción, cuándo es permanente. Aunque sea:memory:, es SQLite real con sus reglas —cruza la frontera de la base de datos—. - (c) Dentro de casa. El
FakeBookingRepositoryguarda el objeto en undictde tu proceso. Nada sale de la memoria de Python; no hay recurso externo. Es un doble, no una frontera. - (d) Frontera (red).
chargemanda unPOSTpor TCP a un servidor —aunque el servidor corra en otro hilo de tu misma máquina, la petición cruza la pila de red—. Es la frontera HTTP. - (e) Frontera (sistema de archivos).
export_bookingsescribe texto en un archivo del disco, que sobrevive al proceso. Cruza la frontera de archivos, aunque el archivo esté en un directorio temporal.
La regla que estás afinando: una frontera es cualquier punto donde tu código toca algo que no vive en tu memoria de Python —el disco, la red, un motor de base de datos—. Un doble, por definición, se queda dentro de casa; por eso nunca ejerce una frontera.
Ejercicio 2 — Qué prueba el test HTTP que un stub no puede. El ejemplo trabajado usa un http.server real en vez de un StubPaymentGateway que devuelva un Receipt fijo. Enumera al menos tres cosas concretas que la versión con servidor real prueba y que el stub deja sin probar.
Ver solución
Con un http.server de verdad, el test ejerce todo el protocolo HTTP del cliente; un stub que devuelve un Receipt fijo salta ese protocolo entero. Tres cosas concretas que solo la versión real prueba:
- Que el cliente arma bien la petición.
HttpPaymentGateway.chargeserializa{"amount_cents": 6000}a JSON, lo pone en el cuerpo, fija el métodoPOSTy la URL/charge. Si algo de eso está mal —el método equivocado, la URL sin la barra, el JSON malformado—, el servidor real lo rechaza o responde distinto. El stub nunca ve la petición, así que no puede delatar un error al armarla. - Que el cliente lee y parsea bien la respuesta. El servidor devuelve JSON con
id,okyamount_cents; el cliente hacejson.loadsy arma unReceipt. Si el cliente esperara otro nombre de campo, o no supiera decodificar el cuerpo, fallaría contra el servidor real. El stub entrega unReceiptya hecho, sin pasar por el parseo. - Que el ida-y-vuelta ocurre de verdad sobre la red. Que el
amount_centsvuelva como6000prueba que el monto viajó en el cuerpo, llegó al servidor y regresó —un circuito completo—. El stub no manda nada; su6000nunca sale de tu memoria.
(Además, la versión real permite probar el timeout, un ok: false, un 500 del servidor —caminos que el stub, que siempre devuelve lo mismo, no toca—.) La moraleja: el stub prueba que tu lógica de negocio reacciona bien a un recibo; el servidor real prueba que tu cliente HTTP habla bien el protocolo. Son dos cosas distintas, y los bugs de integración viven en la segunda.
Ejercicio 3 — Nombra las fronteras de un flujo. El flujo de cancelar una reserva en Reservo, con todo real, hace: lee la reserva del SqliteBookingRepository, calcula el reembolso, reembolsa por el HttpPaymentGateway, guarda el estado cancelado, y escribe una línea en un archivo de auditoría. Lista cada frontera que cruza y con qué herramienta de la stdlib la probarías.
Ver solución
El flujo cruza tres fronteras (dos de ellas dos veces):
- Leer la reserva del repositorio → frontera de base de datos.
repo.get(booking_id)consulta SQLite. Se prueba consqlite3(lecciones 3 y 4), en:memory:o en un archivo temporal. - Reembolsar por el gateway → frontera HTTP.
payments.refund(...)haría unPOSTal servidor de pagos. Se prueba con unhttp.serverde la stdlib levantado en un hilo con puerto efímero (lección 6). - Guardar el estado cancelado → frontera de base de datos otra vez.
repo.save(booking)vuelve a escribir en SQLite, con su transacción y sucommit. - Escribir la línea de auditoría → frontera de archivos. Abrir un archivo y añadir una línea toca el sistema de archivos. Se prueba con
tmp_pathde pytest (lección 5).
El cálculo del reembolso (refund_cents), en cambio, pasa dentro de casa: es aritmética de dominio, sin recurso externo, y se prueba con un unit test. Nombrar las fronteras de un flujo es el primer paso para decidir qué probar en cada una y qué doblar alrededor —justo lo que la lección 7 formaliza como regla—.
Resumen y siguiente paso
En esta lección diste el salto que define el módulo: de integrar dos componentes en proceso a probar donde tu código toca el mundo exterior. Con las puertas de tu casa viste que hay acciones que pasan bajo tus reglas y acciones que cruzan hacia sistemas que no controlas —el correo, el banco, la red—, y que probar esas segundas necesita técnicas propias. Conociste las tres fronteras de Reservo —base de datos, archivos, HTTP— y la herramienta de la stdlib de cada una. Y viste, con salida de pytest, un test cruzando una frontera real en verde: el HttpPaymentGateway cobrando 6000 centavos contra un http.server de verdad, con todo el protocolo HTTP ejercido, algo que ningún stub en memoria prueba.
Antes de avanzar deberías poder: distinguir una acción que cruza una frontera (toca disco, red o base de datos) de una que pasa dentro del proceso; nombrar las tres fronteras de Reservo y su herramienta de la stdlib; y explicar qué prueba un test contra un recurso real que un doble deja sin probar.
Lo que sigue es clavar la definición hasta que no quede ninguna ambigüedad. En la lección 2 vamos a decir con precisión qué es —y qué no es— una frontera: por qué una costura en proceso (la interfaz del repositorio) no es lo mismo que la frontera del recurso (el motor de SQLite), y por qué la frontera es a la vez donde más tienta doblar y donde más se esconden los bugs de integración. Con esa distinción afilada, cada una de las cuatro fronteras que siguen cae en su lugar.
Recursos
- Documentación de pytest — Getting Started — la puerta de entrada oficial a pytest, la herramienta con la que ejecutamos y citamos cada salida del módulo; útil para reconfirmar tu entorno (Python 3.14, pytest 9.1.1) antes de empezar.
http.server— Servidores HTTP de la stdlib (documentación de Python) — la referencia del módulo con el que levantamos elPaymentGatewayde mentira servido por HTTP de verdad; cero dependencias externas, todo lo que trae Python.urllib.request— Cliente HTTP de la stdlib (documentación de Python) — la referencia del cliente con el queHttpPaymentGatewayarma y manda elPOST; en particularurlopeny su parámetrotimeout, que usamos en la lección 6.testing-backend-applications-guide— la guía hermana del otro lado de la frontera dura: probar una app web de verdad (framework, rutas, HTTP de punta a punta), que este módulo deja fuera trabajando solo conhttp.servermínimo de la stdlib.