Módulo 6: Fronteras reales: DB, archivos, HTTP
2. Qué es una frontera
Descripción
La lección 1 te dio la intuición —las puertas de tu casa— y las tres fronteras de Reservo. Ahora hay que convertir esa intuición en una definición con filo, porque de ella depende todo lo que sigue: si no distingues con precisión una frontera de lo que no es una frontera, no sabrás qué probar en cada lección ni por qué. La palabra se usa suelta —"la frontera del sistema", "un test de frontera"— y conviene fijarla. Una frontera es el punto donde tu código deja de operar sobre objetos que viven en tu memoria de Python y toca un recurso que no controlas: el disco, la red, un motor de base de datos, el reloj del sistema. Del lado de acá de la frontera, tú mandas —creas objetos, los pasas, los comparas, y todo es determinista y tuyo—. Del lado de allá, manda otro sistema, con sus reglas de serialización, su persistencia, sus tiempos y sus formas de fallar.
Hay una distinción fina que esta lección existe para clavar, porque es donde más gente se enreda: una costura no es una frontera. Recuerda la costura del módulo 1 —el punto donde BookingService se conecta con el BookingRepository a través de una interfaz—. Esa costura es un lugar donde tú eliges entre poner un doble o poner lo real; es un concepto de diseño, una junta en tu código. La frontera es otra cosa: es lo que hay del lado real de esa costura cuando eliges lo real. Si en la costura del repositorio pones el FakeBookingRepository, no cruzas ninguna frontera: el fake vive en tu memoria. Si pones el SqliteBookingRepository, cruzas la frontera de la base de datos: ahora hay un motor de SQLite del otro lado. La costura es la puerta; la frontera es lo que hay al cruzarla. Una interfaz puede o no dar a una frontera; la del repositorio da a la base de datos, la del reloj da a la frontera del tiempo del sistema, la del gateway da a la red.
Conexión con el módulo: esta lección es la definición que ordena las cuatro fronteras que siguen. La 3 y la 4 ejercen la de base de datos, la 5 la de archivos, la 6 la de HTTP; todas son casos de lo que aquí definimos en abstracto. Y prepara la regla de la lección 7: si sabes que una frontera es "un recurso que no controlas", entiendes por qué la regla dice dobla lo que no controlas en el camino que no pruebas, toca lo real en la frontera que sí pruebas. La frontera con el módulo 7 se respeta: aquí definimos qué es una frontera y por qué importa; cómo aislar el estado real que vive del otro lado —para que las pruebas no se pisen— es allá.
Analogía: el mostrador de una tienda
Piensa en el mostrador de una tienda como la línea que separa dos mundos. De tu lado del mostrador, tú tienes el control: eliges el producto, lo miras, lo dejas, cambias de opinión, todo sin consecuencias y a tu ritmo. En cuanto pones el producto en el mostrador y pagas, cruzas una línea: el producto entra en el sistema de la tienda —su inventario, su caja, su base de datos de ventas—, y ahí ya no mandas tú. La venta queda registrada aunque te vayas; el sistema puede estar lento; la tarjeta puede rechazarse; el ticket sale en el formato de la tienda, no en el tuyo. El mostrador es la frontera: la línea donde tu mundo controlado toca un sistema ajeno con sus propias reglas.
Ahora fíjate en algo sutil, que es justo la distinción costura/frontera. El mostrador en sí —el mueble, el lugar donde se hace el intercambio— es la costura: el punto de contacto. Lo que hay del otro lado —el sistema de la tienda, real, con su inventario y su caja— es la frontera. Podrías, para ensayar, poner del otro lado del mostrador a un amigo que hace como si cobrara y te da un ticket de mentira: entonces el mostrador sigue ahí (la costura), pero no cruzaste ninguna frontera real —tu amigo es un doble, no el sistema de la tienda—. La costura es el mostrador; la frontera es que del otro lado haya un sistema de verdad. En Reservo, la costura del repositorio es la interfaz save/get; la frontera es que del otro lado haya un SQLite real serializando a disco. Probar la frontera es poner el sistema de verdad del otro lado del mostrador y ver si el intercambio funciona con sus reglas puestas.
La costura y la frontera, con código
Veámoslo en Reservo, porque el contraste es limpio. Aquí está la costura del repositorio con un doble de un lado y con lo real del otro. La costura —la forma de llamarlo— es idéntica; lo que cambia es si hay una frontera del otro lado.
# La MISMA costura (la interfaz del repositorio), dos veces:
# (1) con un doble: NO se cruza ninguna frontera
repo = FakeBookingRepository() # guarda en un dict, en TU memoria
service.book(FOCUS, ANA, START, END) # todo pasa dentro del proceso
# (2) con lo real: se cruza la frontera de la base de datos
repo = SqliteBookingRepository(sqlite3.connect("reservo.db")) # motor de SQLite
service.book(FOCUS, ANA, START, END) # la reserva cruza al disco, como texto
En (1), service.book corre su lógica y repo.save mete el objeto en un dict. Nada sale de la memoria de Python: no hay serialización, no hay disco, no hay nada que no controles. La costura existe —hay una interfaz save/get— pero no da a ninguna frontera, porque del otro lado hay un doble. En (2), la misma línea repo.save cruza hacia SQLite: el objeto se serializa a una fila de texto, se escribe en un archivo del disco, y una transacción decide cuándo es permanente. Ahí sí hay una frontera, con todas sus reglas. La costura no cambió; lo que cambió es qué hay del otro lado. Esa es la distinción entera.
Ejemplo trabajado: la frontera deja huella en el disco
La forma más contundente de ver una frontera es comprobar que deja una huella fuera de tu proceso. Un doble no puede: vive en tu memoria y desaparece con ella. Una frontera de archivos o de base de datos escribe bytes en el disco que siguen ahí. Probémoslo con dos tests gemelos: reservar con el fake no crea ningún archivo; reservar con SQLite en un archivo sí crea un archivo real, con bytes de verdad, en el disco.
# tests/test_what_is_a_boundary.py — la frontera deja huella; el doble no
import sqlite3
from datetime import datetime
from reservo.calendar import Calendar
from reservo.doubles import (FakeBookingRepository, 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)
CLOCK = datetime(2026, 3, 1, 9)
def make_service(repo):
return BookingService(Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo)
def test_fake_repo_crosses_no_boundary(tmp_path):
# El fake guarda en un dict de tu proceso: no toca el disco.
repo = FakeBookingRepository()
make_service(repo).book(FOCUS, ANA, START, END)
assert list(tmp_path.iterdir()) == [] # nada quedo en el disco
def test_sqlite_file_repo_crosses_the_disk_boundary(tmp_path):
# SQLite en archivo escribe bytes reales en el disco: cruza la frontera.
path = tmp_path / "reservo.db"
repo = SqliteBookingRepository(sqlite3.connect(path))
make_service(repo).book(FOCUS, ANA, START, END)
assert path.exists() # el archivo existe
assert path.stat().st_size > 0 # y tiene bytes de verdad
El tmp_path es un directorio temporal que pytest te da limpio (lo estudiamos a fondo en la lección 5); aquí lo usamos como "el disco" y comprobamos qué queda en él. El primer test reserva con el fake y afirma que el directorio sigue vacío: la reserva se guardó en un dict, sin tocar el disco. El segundo reserva con SQLite en un archivo y afirma que el archivo existe y tiene bytes —path.stat().st_size > 0—: la reserva cruzó la frontera y dejó huella fuera de tu proceso.
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_what_is_a_boundary.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items
tests/test_what_is_a_boundary.py::test_fake_repo_crosses_no_boundary PASSED [ 50%]
tests/test_what_is_a_boundary.py::test_sqlite_file_repo_crosses_the_disk_boundary PASSED [100%]
============================== 2 passed in 0.02s ===============================
Dos verdes que dicen la diferencia sin retórica. Con el fake, el disco quedó vacío: la costura del repositorio no dio a ninguna frontera, todo pasó en la memoria de Python. Con SQLite en un archivo, quedó un archivo con bytes reales: la misma costura, pero esta vez del otro lado había una frontera —el sistema de archivos— y la reserva la cruzó. Un doble jamás pasaría el segundo test, porque un doble no escribe en el disco; y esa es precisamente la definición operativa de una frontera: el punto donde tu código deja una huella —o depende de un recurso— fuera de tu proceso.
Tres cosas que solo pasan en una frontera
Vale la pena nombrar qué es lo que cambia al cruzar una frontera, porque son las tres fuentes de todo bug de integración y de toda fricción de este módulo.
Uno: la serialización. Del lado de acá tienes objetos de Python —un datetime, un int, un Booking—. Del lado de allá, casi ningún recurso los entiende: SQLite guarda texto y números, un archivo guarda bytes, HTTP transporta texto. Cruzar la frontera convierte tus objetos a otro formato, y al volver hay que reconstruirlos. Ahí nace el bug del datetime→str del módulo 1: la frontera serializó el datetime a texto y nadie lo reconstruyó. Un doble no serializa nada —guarda el objeto tal cual—, así que nunca expone este problema. Solo la frontera lo hace.
Dos: la persistencia y la externalidad. Lo que cruza una frontera existe fuera de tu proceso: la fila sigue en el archivo de SQLite cuando cierras la conexión, el CSV sigue en el disco cuando el test termina, la venta queda en el sistema de la tienda cuando te vas. Eso es una virtud (por eso persistimos datos) y un peligro (por eso un test puede dejar basura que rompe al siguiente). El aislamiento de ese estado externo es el tema del módulo 7; aquí basta con saber que existe.
Tres: el no-determinismo y la latencia. Del lado de acá, una llamada devuelve al instante y siempre igual. Del lado de allá, no lo controlas: el disco puede estar lento, la red puede tardar o caerse, el servidor puede responder tarde o con un error. Por eso una prueba de frontera necesita cuidados que un unit test no —timeouts, recursos efímeros, tolerancia al orden— para no volverse lenta ni intermitente. Esa disciplina es la lección 7.
Y aquí está la tensión que da nombre a media guía: la frontera es a la vez donde más tienta doblar —porque es lento, externo y no determinista— y donde más se esconden los bugs —porque la serialización, la persistencia y el no-determinismo solo ocurren ahí—. Doblar la frontera te da velocidad y determinismo, pero te ciega justo al lugar donde vive el fallo. Por eso no doblamos todas las fronteras: doblamos las que no estamos probando y dejamos real la que sí. La lección 7 lo vuelve una regla; este módulo entero es aprender a tocar la frontera correcta sin heredar el costo de todas.
Errores comunes
Confundir la costura con la frontera. Qué pasa: alguien dice "probé la frontera del repositorio" cuando en realidad usó el FakeBookingRepository. Por qué pasa: la costura (la interfaz) es la misma con el fake y con lo real, así que se sienten iguales. Cómo detectarlo: pregúntate si del otro lado de la costura hay un recurso que no controlas —un motor de base de datos, un archivo, un servidor— o un objeto de tu memoria. Si es un objeto de tu memoria, no cruzaste ninguna frontera. Cómo corregirlo: la costura es dónde eliges; la frontera es qué hay del lado real. Probar la frontera exige poner el recurso real del otro lado, no solo llamar a la interfaz.
Creer que "rápido y en memoria" nunca es una frontera. Qué pasa: alguien usa sqlite3.connect(":memory:") y concluye que, como es memoria y es rápido, no cruza ninguna frontera. Por qué pasa: "en memoria" suena a "dentro de mi proceso". Cómo detectarlo: pregúntate si el recurso tiene reglas propias de serialización, transacciones y tipos —aunque viva en RAM—. :memory: es un motor de SQLite completo: serializa a filas, tiene transacciones, devuelve el datetime como str. Cruza la frontera de la base de datos aunque no toque el disco. Cómo corregirlo: la frontera la define quién manda del otro lado (un motor con sus reglas), no si hay disco. :memory: es SQLite real; un dict no. La lección 4 desarrolla justo esta diferencia.
Tratar todas las fronteras como una sola. Qué pasa: alguien prueba la frontera de la base de datos con cuidado pero asume que la de archivos y la de HTTP "son parecidas" y las descuida. Por qué pasa: "recurso externo" suena a una sola categoría. Cómo detectarlo: si tu test de HTTP no considera timeouts, o tu test de archivos no considera la serialización a texto, estás aplicando la intuición de una frontera a otra. Cómo corregirlo: cada frontera tiene su técnica —transacciones en la base de datos, tmp_path en archivos, servidor efímero y timeout en HTTP—. Comparten la idea (un recurso real que no controlas) pero no las herramientas. Por eso el módulo dedica una lección a cada una.
Ejercicios
Ejercicio 1 — Costura, frontera o ninguna. Para cada situación, di si describe una costura (un punto de conexión en tu diseño), una frontera (un recurso externo del lado real), o ninguna (lógica pura): (a) la interfaz BookingRepository con sus métodos save/get; (b) el motor de SQLite escribiendo una fila en reservo.db; (c) la función price_cents(room, member, hours); (d) el HttpPaymentGateway haciendo un POST a un servidor; (e) el punto donde BookingService recibe su colaborador payments por el constructor.
Ver solución
- (a) Costura.
BookingRepositoryes una interfaz: el punto de diseño dondeBookingServicese conecta con algún repositorio. Es dónde eliges doblar o integrar; no es un recurso, es una junta. - (b) Frontera. El motor de SQLite escribiendo en
reservo.dbes un recurso externo con sus reglas —serialización, transacción, disco—. Es lo que hay del lado real de la costura del repositorio. - (c) Ninguna.
price_centses lógica pura: recibe datos, devuelve un entero, sin conexión con colaboradores ni recurso externo. Ni costura ni frontera; el dominio. - (d) Frontera. El
POSTa un servidor cruza la red hacia un recurso que no controlas —la frontera HTTP—. (La costura correspondiente es la interfazPaymentGateway; elPOSTes cruzarla hacia lo real.) - (e) Costura. El punto donde se inyecta
paymentses la costura de pagos: la junta por la que entra el colaborador. Que del otro lado haya unStubPaymentGateway(sin frontera) o unHttpPaymentGateway(con frontera HTTP) lo decides ahí.
La regla que estás afinando: la costura es un concepto de diseño (dónde se conectan las piezas y dónde eliges); la frontera es un concepto de recurso (qué hay del lado real, fuera de tu proceso). Una costura puede dar a una frontera o a un doble; la lógica pura no tiene ninguna de las dos.
Ejercicio 2 — Por qué :memory: sí cruza una frontera. Un compañero dice: "una base :memory: vive en RAM, igual que el dict del FakeBookingRepository; los dos están en memoria, así que ninguno cruza una frontera". Explica por qué se equivoca, y qué prueba :memory: que el dict no.
Ver solución
Se equivoca porque confunde dónde vive el dato (la RAM, en ambos casos) con quién manda sobre él y con qué reglas (lo que define una frontera). El dict del fake es una estructura de datos de Python: guarda el objeto Booking tal cual, sin reglas propias; tú mandas por completo. Una base sqlite3.connect(":memory:") es un motor de SQLite entero que resulta estar en RAM: tiene un esquema con tipos de columna, serializa el objeto a una fila de texto y números, gestiona transacciones con commit y rollback, y al leer te devuelve el datetime como str porque la columna es TEXT. Todas esas son reglas del recurso, no tuyas: cruzas su frontera.
Lo que :memory: prueba y el dict no es precisamente todo lo que ocurre en la frontera: la serialización (el datetime→str, el int que sí sobrevive), el comportamiento de las transacciones, la reconstrucción del objeto desde columnas. El dict nunca serializa nada, así que jamás expone el bug del datetime ni ejerce una transacción. Por eso :memory: es un SQLite real y útil para probar la frontera de la base de datos —rápido, sin disco, pero con todas las reglas del motor puestas—, mientras que el dict es un doble que se queda dentro de casa. La única cosa que :memory: no prueba, por vivir en RAM, es la persistencia en disco; eso es la lección 4.
Ejercicio 3 — Las tres cosas de la frontera, en un bug. Recuerda el bug del módulo 1: SqliteBookingRepository.get devuelve start como str, y una pantalla que formatea la fecha se rompe. Explica cuál de las "tres cosas que solo pasan en una frontera" (serialización, persistencia/externalidad, no-determinismo) es la raíz de ese bug, y por qué ningún doble lo habría expuesto.
Ver solución
La raíz es la serialización. SQLite no tiene un tipo nativo para datetime, así que al cruzar la frontera el objeto se convierte a texto (.isoformat() en save) para caber en la columna TEXT, y al volver sale como str porque nadie lo reconstruye a datetime. El dato cambió de forma en la frontera: eso es serialización, la primera de las tres cosas. No es persistencia (el bug aparece igual en :memory:, sin disco) ni no-determinismo (es perfectamente repetible, siempre falla igual); es puramente la conversión de formato que la frontera impone.
Ningún doble lo habría expuesto porque un doble no serializa: el FakeBookingRepository guarda el objeto Booking en un dict y lo devuelve idéntico, con su datetime intacto, porque nunca lo convierte a texto. La serialización solo ocurre cuando hay un recurso real del otro lado que exige otro formato —una tabla de texto, un archivo, un cuerpo HTTP—. Por eso el bug vive exactamente en la frontera y solo la pieza real lo revela: el doble se queda del lado de acá, donde los objetos nunca cambian de forma. Esta es la razón profunda de todo el módulo: las tres cosas que solo pasan en la frontera son también las tres clases de bug que solo la frontera expone.
Resumen y siguiente paso
En esta lección clavaste la definición que ordena el módulo: una frontera es el punto donde tu código toca un recurso que no controlas —disco, red, base de datos—, del otro lado de una costura. Con el mostrador de la tienda separaste las dos ideas: la costura es el punto de conexión de tu diseño (dónde eliges doblar o integrar); la frontera es qué hay del lado real cuando eliges lo real. Lo viste con código —la misma costura del repositorio, con un doble (sin frontera) y con SQLite (con frontera)— y con salida de pytest: el fake no deja huella en el disco, SQLite en archivo sí. Y nombraste las tres cosas que solo pasan en una frontera —serialización, persistencia/externalidad, no-determinismo—, que son las tres fuentes de todo bug de integración y la razón por la que la frontera es a la vez donde más tienta doblar y donde más se esconde el fallo.
Antes de avanzar deberías poder: distinguir una costura (concepto de diseño) de una frontera (recurso externo); explicar por qué :memory: cruza la frontera de la base de datos aunque viva en RAM; y nombrar las tres cosas que solo ocurren en una frontera y ligar cada una a una clase de bug.
Lo que sigue es cruzar la primera frontera a fondo. En la lección 3 abrimos la de base de datos por su regla más propia —la transacción—: vas a ver con salida real qué hace un commit, qué hace un rollback, y por qué una escritura sin confirmar es invisible para otra conexión. Es la mecánica exacta que hace que SqliteBookingRepository persista de verdad, y la palanca que el módulo 7 usará para aislar tests.
Recursos
sqlite3— DB-API para SQLite (documentación de Python) — la referencia del recurso real que hace de frontera de base de datos en todo el módulo; útil para ver cómo SQLite maneja (y no maneja) los tipos de Python, la raíz de la serialización de la que habla esta lección.- Documentación de pytest — Cómo usar directorios y archivos temporales (
tmp_path) — la referencia del fixture con el que, en el ejemplo, usamos "el disco" para comprobar que la frontera deja huella; lo estudiamos a fondo en la lección 5. - Martin Fowler — IntegrationTest — el marco que define qué prueba una integración; contexto útil para entender por qué probar la frontera (y no solo la costura) es lo que da valor a una prueba de integración.
test-doubles-and-test-data-guide— la guía hermana donde se construyó la costura y los dobles; si la distinción costura/inyección te quedó floja, es el lugar para repasarla antes de seguir.