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

7. Rápido y determinista: cuándo tocar lo real y cuándo doblar

Descripción

Ya cruzaste las tres fronteras: la base de datos con su transacción, el archivo con tmp_path, el HTTP con http.server. En cada una, sin nombrarlo del todo, aplicaste dos disciplinas que ahora toca destilar, porque son las que separan una suite de integración que la gente quiere correr de una que evita como la peste. La primera es la técnica: cómo hacer que una prueba de frontera —que toca disco, red o un motor de base de datos— sea igual de rápida y confiable que un unit test. Lo lograste con tres herramientas repetidas: una base :memory: o un tmp_path en lugar de un recurso compartido, un servidor en un hilo con puerto efímero en lugar de uno externo, y un timeout que acota la espera. Esas tres claves convierten recursos lentos y no deterministas por naturaleza en pruebas de milisegundos, aisladas y repetibles.

La segunda disciplina es el criterio, y es la regla que gobierna todo el módulo —de hecho, toda la integración—. En cualquier prueba de frontera hay una frontera que estás probando y varias que solo están de paso. La regla: toca lo real en la frontera que estás probando; dobla lo que no controlas o es lento en el camino que no estás probando. Si pruebas que book persiste en la base de datos, el repositorio va real (es la costura bajo prueba) y el pago, el reloj y el correo van doblados (son colaboradores de paso, lentos o externos). Si pruebas que book cobra bien por HTTP, el gateway va real y el repositorio va doblado. La misma acción —book— se prueba con distintas piezas reales según qué frontera estés examinando. Esta lección hace explícita esa regla, la justifica con los dos extremos que evita, y la aplica frontera por frontera a Reservo con salida real.

Conexión con el módulo: esta lección es la síntesis. Recoge las tres técnicas que usaste en las lecciones 3 a 6 y las nombra como principios; y formula la regla de decisión que estaba implícita en cada "qué doblo y qué dejo real". Es también el puente al módulo 7 de la guía: la técnica de recursos efímeros y autolimpiables que aquí ves como "para que sea rápido y determinista" se convierte allá en el tema central —el aislamiento sistemático de datos y recursos—. La frontera con ese módulo se respeta: aquí damos la regla y las tres claves; el arte de aislar con fixtures que crean y destruyen recursos, y el rollback como técnica de aislamiento, es el módulo 7. Aquí decides qué tocar real; allá aprendes a aislar lo real que tocas.

Analogía: el foco de la linterna

Imagina que buscas algo en un cuarto oscuro con una linterna. No iluminas todo el cuarto por igual —no puedes, y no hace falta—: apuntas el foco a la esquina que estás revisando y la ves nítida, mientras el resto del cuarto queda en penumbra, apenas insinuado. Cuando terminas con esa esquina y pasas a otra, mueves el foco: ahora esa está iluminada y la anterior vuelve a la sombra. La linterna es útil precisamente porque concentra la luz: si intentaras iluminar el cuarto entero con la misma intensidad, necesitarías una lámpara enorme, gastarías muchísima energía, y encima el reflejo de todo a la vez te encandilaría y no verías nada con claridad.

Una prueba de frontera es esa linterna. La frontera que estás probando es la esquina iluminada —real, nítida, examinada de cerca—; todo lo demás —los colaboradores de paso— queda en penumbra, doblado, apenas lo justo para que el flujo corra. Cuando cambias de frontera, mueves el foco: la que era real se doblega y la nueva se enciende. Iluminar el cuarto entero —dejar todo real— es el error del que hablaremos: caro, lento, y tan lleno de cosas que fallan a la vez que no sabes qué estás probando. Dejar todo a oscuras —doblar todo— es el otro error: no ves ninguna frontera, no pruebas ninguna junta. El arte es apuntar el foco a una frontera, dejar el resto en sombra, y saber exactamente qué esquina estás mirando.

Las tres claves: rápido y determinista

Antes de la regla, consolidemos la técnica. Una prueba de frontera es, por naturaleza, más lenta y menos determinista que un unit test —toca recursos que no controlas—. Tres herramientas, que ya usaste, la devuelven al terreno de lo rápido y confiable:

  • Recursos en memoria o temporales, nunca compartidos. Para la base de datos, sqlite3.connect(":memory:"): SQLite real, sin el costo del disco, y aislado porque cada conexión nace vacía. Para archivos, tmp_path: un archivo real en un directorio único por test, que pytest borra solo. La alternativa —una base de datos compartida, un archivo con nombre fijo— es lenta (disco, red) y frágil (un test le deja datos al siguiente). La clave: real pero tuyo y efímero.
  • El servidor en un hilo, en un puerto efímero. Para HTTP, un http.server levantado en un hilo daemon con puerto 0. Es un servidor de verdad, local, que arranca en microsegundos y no choca con nada. La alternativa —apuntar a un servidor externo, o a un puerto fijo— es lenta, no determinista (depende de que el otro esté vivo y la red responda) y propensa a colisiones. La clave: servidor real pero local y efímero.
  • Timeouts que acotan la espera. Toda llamada que cruza la red lleva un timeout, para que un recurso que se cuelga no congele la suite. La alternativa —una llamada sin límite— convierte cualquier tropiezo del otro extremo en un test colgado para siempre. La clave: nunca esperar indefinidamente en una frontera.

Estas tres claves tienen algo en común: mantienen el recurso real —ejerces la serialización, la transacción, el protocolo HTTP de verdad— pero bajo tu control, local y desechable. Real sin ser producción; ese es el equilibrio de una buena prueba de frontera.

La regla de decisión

Ahora el criterio, en una frase que conviene memorizar:

Toca lo real en la frontera que estás probando; dobla lo que no controlas, es lento o es externo en el camino que no estás probando.

Desglosémosla. "La frontera que estás probando" es la costura cuyo comportamiento quieres verificar de verdad —su serialización, su transacción, su protocolo—: esa va real, porque doblarla sería doblar justo lo que quieres examinar. "El camino que no estás probando" son los otros colaboradores que el flujo toca de pasada: esos van doblados, porque su realidad no aporta nada a lo que pruebas y sí trae costo (lentitud) o riesgo (no-determinismo, efectos externos como cobrar una tarjeta). La regla es la linterna hecha criterio: enciende una frontera, deja el resto en penumbra.

Fíjate en la consecuencia práctica: la misma acción se prueba con distintas configuraciones según la frontera bajo examen. book toca dos fronteras —persiste en la base de datos y cobra por el gateway—. Para probar la persistencia, el repositorio va real y el gateway doblado. Para probar el cobro, el gateway va real y el repositorio doblado. No hay una sola "prueba de integración de book"; hay una por cada frontera que quieras iluminar, cada una con su foco en un sitio distinto. Veámoslo con código.

Ejemplo trabajado: la misma book, dos focos

Dos pruebas de book. En la primera, la frontera bajo examen es la base de datos: el repositorio va real (:memory:) y todo lo demás —pago, reloj, correo— va doblado. En la segunda, la frontera es HTTP: el gateway va real (contra un http.server) y el repositorio va doblado (FakeBookingRepository). El foco se mueve; la regla se ve.

# tests/test_real_vs_double.py — la misma book, el foco en distinta frontera

# Frontera bajo prueba: BASE DE DATOS.
# El repo es REAL (:memory:); pago/reloj/correo son DOBLES.
def test_db_boundary_real_everything_else_doubled():
    repo = SqliteBookingRepository(sqlite3.connect(":memory:"))   # REAL
    service = BookingService(
        Calendar(),
        FixedClock(CLOCK),              # doble: el reloj no es la frontera aqui
        StubPaymentGateway(ok=True),    # doble: el pago no es la frontera aqui
        SpyEmailSender(),               # doble
        repo,
    )
    booking = service.book(FOCUS, ANA, START, END)
    assert repo.get(booking.id).price_cents == 6000


# Frontera bajo prueba: HTTP.
# El gateway es REAL (http.server); el repo es un DOBLE.
def test_http_boundary_real_repo_doubled(gateway_url):
    repo = FakeBookingRepository()                     # doble: la DB no es la frontera aqui
    service = BookingService(
        Calendar(),
        FixedClock(CLOCK),
        HttpPaymentGateway(gateway_url),               # REAL: la frontera bajo prueba
        SpyEmailSender(),
        repo,
    )
    booking = service.book(FOCUS, ANA, START, END)
    assert repo.get(booking.id).price_cents == 6000

Las dos hacen book(FOCUS, ANA, START, END) y las dos verifican price_cents == 6000, pero iluminan fronteras distintas. La primera pone el SqliteBookingRepository real y dobla el gateway con un StubPaymentGateway: prueba que la reserva persiste de verdad cruzando la frontera de la base de datos, sin pagar el costo ni el riesgo de una llamada HTTP real que aquí no aporta. La segunda pone el HttpPaymentGateway real contra el http.server y dobla el repositorio con un FakeBookingRepository: prueba que el cobro cruza HTTP de verdad, sin arrastrar el costo del disco que aquí no importa. Cada foco donde toca.

Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):

python3 -m pytest tests/test_real_vs_double.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items

tests/test_real_vs_double.py::test_db_boundary_real_everything_else_doubled PASSED [ 50%]
tests/test_real_vs_double.py::test_http_boundary_real_repo_doubled PASSED [100%]

============================== 2 passed in 0.54s ===============================

Dos verdes que muestran la regla en acción. La misma book probada dos veces, con el foco en la base de datos primero y en HTTP después. Ninguna dejó todo real —eso sería lento y frágil— ni todo doblado —eso no probaría ninguna frontera—. Cada una encendió exactamente una frontera y dobló el resto. Eso es una prueba de integración bien enfocada: nítida donde importa, en penumbra donde no.

Por qué no todo real, y por qué no todo doblado

La regla se entiende mejor por los dos extremos que descarta.

Todo real es un end-to-end disfrazado. Si dejas real el repositorio y el gateway y el reloj y el correo, tu "prueba de integración" cobra tarjetas de verdad, manda correos de verdad, depende de la red, y tarda segundos. Cuando falla, ¿fue el repositorio, el gateway, la red, el correo? No lo sabes: iluminaste el cuarto entero y todo pudo romperse a la vez. Ese es un test end-to-end —legítimo en su lugar, en la punta de la pirámide, pocos y lentos—, pero disfrazarlo de prueba de una frontera te da lo peor de ambos: lento, frágil, y sin decirte qué costura falló. Además, algunos colaboradores no puedes dejarlos reales en un test: un PaymentGateway de producción cobraría dinero de verdad.

Todo doblado no prueba ninguna frontera. Si doblas el repositorio y el gateway y todo lo demás, tu test corre rapidísimo y es determinista... pero no cruzó ninguna frontera. No ejerció la serialización de la base de datos, ni el protocolo HTTP, ni la escritura a un archivo. Es un unit test —bueno para la lógica de orquestación de BookingService, inútil para las juntas con lo real—. Recuerda el módulo 1: un unit test verde con todo doblado puede esconder una integración rota, porque ningún doble ejerce la frontera donde vive el bug. Doblar todo es apagar la linterna.

La regla vive en el medio: exactamente una frontera real (la que pruebas) y el resto doblado (lo que no pruebas). Así obtienes lo mejor de los dos mundos: pruebas de verdad la junta que te importa —con su serialización, su transacción, su protocolo— y mantienes el test rápido, determinista y sin efectos externos, porque todo lo demás está en penumbra. Y como cada frontera tiene su propia prueba enfocada, entre todas cubres el sistema sin ningún test caro que lo intente todo de golpe.

Errores comunes

Doblar la frontera que dices estar probando. Qué pasa: alguien titula un test "integración del repositorio" pero usa el FakeBookingRepository. Por qué pasa: el fake es más cómodo y rápido, y la costumbre pesa. Cómo detectarlo: pregúntate cuál es la frontera que el test dice examinar, y si esa pieza es real. Si es un doble, no estás probando esa frontera —apuntaste el foco a otro lado—. Cómo corregirlo: la frontera bajo prueba va real, siempre; ese es el punto entero. Dobla los colaboradores de paso, nunca la costura que examinas.

Dejar real un colaborador que no es la frontera bajo prueba. Qué pasa: para "que sea más realista", alguien deja el PaymentGateway real en un test que examina la persistencia en la base de datos. Por qué pasa: "más real siempre es mejor" es una intuición seductora. Cómo detectarlo: si tu prueba de una frontera toca otra frontera real que no estás examinando —cobra de verdad, manda correos, depende de la red—, heredaste su costo y su riesgo sin ganar nada. Cómo corregirlo: solo la frontera bajo prueba va real; el resto se dobla, aunque "podrías" dejarlo real. Más piezas reales no es más riguroso: es más lento, más frágil y más difícil de diagnosticar cuando falla.

Probar una frontera con un recurso compartido o sin timeout. Qué pasa: alguien deja real la frontera correcta, pero apunta a una base de datos compartida, a un archivo fijo, o a un servidor sin timeout. Por qué pasa: se enfoca en qué dejar real y olvida las tres claves de cómo hacerlo rápido y determinista. Cómo detectarlo: si el test correcto en su elección de piezas reales igual es lento, intermitente o depende del orden, falla en la técnica, no en el criterio. Cómo corregirlo: aplica las tres claves —:memory:/tmp_path en vez de compartido, servidor en hilo con puerto efímero, timeout en cada llamada de red—. La regla te dice qué tocar real; las tres claves, cómo tocarlo sin heredar su lentitud.

Ejercicios

Ejercicio 1 — Aplica la regla. Para cada objetivo de prueba, di qué pieza de Reservo va real y cuáles van dobladas: (a) verificar que una reserva persiste correctamente en SQLite; (b) verificar que book cobra el monto correcto por HTTP; (c) verificar que la exportación a CSV escribe las reservas correctas; (d) verificar la lógica de que cancel reembolsa 6000 a 72 h del inicio.

Ver solución
  • (a) Persistencia en SQLite. Real: el SqliteBookingRepository (la frontera bajo prueba). Doblados: el PaymentGateway (stub), el reloj (fixed), el correo (spy). Es test_db_boundary_real_everything_else_doubled.
  • (b) Cobro por HTTP. Real: el HttpPaymentGateway contra un http.server (la frontera bajo prueba). Doblados: el repositorio (fake), el reloj, el correo. Es test_http_boundary_real_repo_doubled.
  • (c) Exportación a CSV. Real: el archivo, con tmp_path (la frontera bajo prueba: export_bookings/import_bookings). Doblado: el repositorio de donde salen las reservas puede ser un FakeBookingRepository (no es la frontera; solo provee los datos). El pago, reloj y correo ni intervienen si armas las reservas directamente.
  • (d) Lógica de refund_cents. Ninguna pieza real de frontera: es lógica pura. Se prueba con un unit test directo (refund_cents(booking, 6000, now) == 6000), sin repositorio, sin gateway, sin nada real. No cruza ninguna frontera, así que la regla de "qué doblar" ni aplica: es dominio puro.

La regla que estás afinando: identifica la frontera bajo prueba, hazla real, dobla el resto. Y si no hay frontera (lógica pura), es un unit test sin nada que doblar.

Ejercicio 2 — El test que lo deja todo real. Un compañero escribe una "prueba de integración definitiva" de book con el SqliteBookingRepository real en un archivo, el HttpPaymentGateway real contra el gateway de pagos de staging, y un reloj real. Enumera tres problemas concretos de ese test y reescribe, en palabras, cómo lo dividirías según la regla.

Ver solución

Tres problemas concretos del test "todo real":

  1. Efectos externos y costo real. Apuntar al gateway de pagos de staging puede mover dinero de verdad o dejar registros en un sistema compartido, y depende de que staging esté vivo y accesible. Un test no debería cobrar tarjetas.
  2. Lento y no determinista. El archivo en disco (con su commit que sincroniza) más la llamada HTTP por la red real suman cientos de milisegundos o segundos, y la red de staging puede tardar o fallar por razones ajenas a tu código. El test será lento e intermitente.
  3. Diagnóstico imposible. Cuando falle, no sabrás si fue el repositorio, el gateway, la red de staging o el reloj: iluminaste el cuarto entero, y cualquier cosa pudo romperse. Un rojo así no señala la costura culpable.

Cómo dividirlo según la regla: dos pruebas enfocadas, más un unit test. Una prueba con el foco en la base de datosSqliteBookingRepository real en :memory: (o tmp_path si de verdad pruebas persistencia en disco), gateway/reloj/correo doblados— que verifica que la reserva persiste. Otra con el foco en HTTPHttpPaymentGateway real contra un http.server local de mentira, repositorio doblado— que verifica que el cobro cruza bien la frontera de la red. Y un unit test para la lógica de precio y reembolso, sin nada real. Así cada prueba es rápida, determinista, sin efectos externos, y cuando una falla te dice exactamente qué frontera se rompió. Lo que el compañero llamó "definitiva" se descompone en piezas nítidas; lo "todo real" a lo sumo cabe como un único end-to-end en la punta de la pirámide, no como la prueba de cada frontera.

Ejercicio 3 — Real pero mal montado. Un test examina la frontera HTTP con el HttpPaymentGateway real —buena elección de pieza real— pero lo apunta a un servidor levantado en el puerto fijo 8080, sin timeout, y que el test no apaga al terminar. La elección de qué es real es correcta; ¿qué falla, y cómo lo arreglas con las tres claves?

Ver solución

La elección de la pieza real es correcta (el gateway HTTP es la frontera bajo prueba, así que va real), pero la técnica de cómo montarlo está mal en las tres claves:

  1. Puerto fijo 8080. Choca con cualquier otra cosa que use ese puerto —otro test, una corrida anterior, otra app—, produciendo fallos intermitentes de Address already in use. Arreglo: puerto efímero (0) y leer el que el sistema eligió con server.server_address[1].
  2. Sin timeout. Si el servidor se cuelga, el test espera para siempre y bloquea la suite. Arreglo: pasar un timeout al cliente (como hace HttpPaymentGateway) y, idealmente, probar que corta con un servidor lento.
  3. No apaga el servidor. El hilo y el puerto quedan ocupados entre tests, ensuciando corridas posteriores. Arreglo: envolver el arranque en un fixture con try/finally (o yield) que llame a shutdown(), join() y server_close() al terminar, y correr el servidor en un hilo daemon.

La moraleja: la regla ("qué tocar real") y las tres claves ("cómo tocarlo") son independientes, y necesitas las dos. Aquí el criterio acertó —el gateway va real— pero la técnica falló, y el resultado es un test correcto en intención pero lento, colisionante y frágil. Aplicar las tres claves lo devuelve al terreno de lo rápido y determinista sin cambiar qué frontera examina.

Resumen y siguiente paso

En esta lección destilaste las dos disciplinas que hacen viable probar en las fronteras. La técnica —las tres claves para que una prueba de frontera sea rápida y determinista—: recursos en memoria o temporales en vez de compartidos (:memory:, tmp_path), servidor en un hilo con puerto efímero, y timeouts que acotan la espera; todas mantienen el recurso real pero tuyo, local y desechable. Y el criterio —la regla de decisión del módulo entero—: toca lo real en la frontera que pruebas; dobla lo que no controlas, es lento o es externo en el camino que no pruebas. Con el foco de la linterna viste por qué: encender una frontera y dejar el resto en penumbra evita los dos extremos —el "todo real" que es un end-to-end lento y ciego, y el "todo doblado" que no prueba ninguna junta—. Y lo comprobaste con la misma book probada dos veces, el foco en la base de datos y luego en HTTP, cada una real donde importa y doblada donde no.

Antes de avanzar deberías poder: nombrar las tres claves de una prueba de frontera rápida y determinista; enunciar y aplicar la regla de qué tocar real y qué doblar; y descomponer un test "todo real" en pruebas enfocadas por frontera.

Lo que sigue es el cierre del módulo, donde juntas las tres fronteras en un solo flujo. En la lección 8, el mini-proyecto: una prueba que cruza las tres fronteras reales de Reservo —cobrar por HTTP, persistir en SQLite, exportar a un archivo— y las verifica todas, en verde, con la justificación de qué quedó real y qué doblado en cada una. Es la regla de esta lección puesta a trabajar en un flujo completo, y la puerta al módulo 7, donde aprenderás a aislar los datos y los recursos que estas pruebas de frontera dejan por todas partes.

Recursos