Módulo 8: Proyecto: contrato + integración de Reservo
5. La prueba de integración de punta a punta: `book`→`get`→`cancel`
Descripción
Con el contrato verde por ambos lados tienes la primera capa de la garantía. Ahora construyes la segunda: el tercer entregable, la prueba de integración de punta a punta. Aquí BookingService deja de hablar con specs y cláusulas y habla con el SqliteBookingRepository real, en un flujo vivo: reserva, guarda, cancela, relee. Es la costura del repositorio cruzada de verdad, con la pieza que corre en producción al otro lado, ejercitada por el orquestador completo. Donde el contrato inspecciona la forma de cada dato, la integración usa los datos en el flujo real —y eso caza una clase de bug que ninguna cláusula ve—.
El método de la lección 1 se cobra aquí: qué doblar y qué mantener real. En esta integración el repositorio va real —es la costura que pruebas—, y el reloj, el pago y el correo van doblados —lo no determinista, lo externo—. Esa mezcla no es pereza ni gusto: es lo que hace que la prueba mida la colaboración BookingService↔repositorio sin heredar el costo y la fragilidad de cobrar tarjetas o esperar a que el reloj avance. Vas a escribir el flujo book→get (guardar y releer una reserva) y el flujo completo book→cancel→get (reservar, cancelar con reembolso, releer el estado cancelado), los dos contra SQLite de verdad, y verlos en verde. Y vas a entender, con la lente del módulo 5, por qué este flujo caza lo que la inspección del contrato no puede.
Conexión con el módulo: esta lección produce el entregable 3 en su forma básica; la lección 6 lo endurece con el aislamiento. El flujo que escribes aquí es el que, en la lección 6, envolverás en una fixture que crea y destruye el recurso o en una transacción que se revierte, para que cada corrida empiece limpia. Y es el complemento del contrato de las lecciones 2 a 4: juntos, contrato e integración cubren las dos formas de que la costura falle —una divergencia en un comportamiento enumerado (contrato) y un bug de uso en el flujo real (integración)—. Aquí ves la segunda mitad de esa cobertura tomar forma.
Analogía: la prueba de manejo del auto ensamblado
Una fábrica de autos prueba cada pieza por separado: el motor en un banco, los frenos en un simulador, la dirección con un torquímetro. Cada pieza pasa su prueba. Pero antes de mandar el auto a la calle, hace algo más: un conductor de pruebas se sube al auto ensamblado y le da una vuelta a la pista. No vuelve a medir la compresión del motor ni la presión de los frenos —eso ya se hizo—; conduce. Acelera y siente si la transmisión engrana, frena en una curva y siente si la dirección responde bajo carga, mete un cambio y escucha si algo vibra. La vuelta a la pista prueba lo que ninguna prueba de banco puede: que las piezas, juntas y en movimiento, colaboran. Un auto con cada pieza aprobada puede fallar en la primera curva porque dos piezas correctas no encajan bajo condiciones reales.
La integración de punta a punta es la vuelta a la pista. El contrato fue las pruebas de banco: cada método del repositorio, cada cláusula, verificada por separado. Ahora subes a BookingService al auto ensamblado —con el SqliteBookingRepository real montado— y le das una vuelta: book acelera (crea y guarda la reserva), cancel frena en la curva (lee la reserva de vuelta, calcula el reembolso, guarda el estado cancelado), get confirma que el auto quedó donde debía. No inspeccionas cada dato; conduces el flujo. Y si dos piezas correctas no encajan bajo carga real —el start que el repositorio devuelve en una forma que cancel no puede usar—, la vuelta a la pista lo descubre donde la prueba de banco no miró: en la curva, con el auto en movimiento.
El tercer entregable, escrito
Empieza por la infraestructura de la prueba: la fixture que da un repositorio real fresco y la función que arma el servicio con la mezcla correcta de real y doblado. Esta es la aplicación directa de la tabla de la lección 1.
# tests/test_end_to_end_integration.py — LA INTEGRACION (entregable 3)
import sqlite3
from datetime import datetime
import pytest
from reservo.calendar import Calendar
from reservo.doubles import 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) # Focus 3 h
CLOCK = datetime(2026, 3, 1, 9) # 9 dias antes -> reembolso completo (6000)
@pytest.fixture
def repo():
conn = sqlite3.connect(":memory:") # base efimera, limpia por test
yield SqliteBookingRepository(conn)
conn.close() # se destruye al terminar el test
def make_service(repo):
# Real la costura que probamos (el repositorio); doblado lo no determinista
# y lo externo (reloj, pago, correo).
return BookingService(Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo)
Mira la fixture repo con cuidado, porque encarna dos decisiones. La primera: el repositorio es real —un SqliteBookingRepository sobre una base :memory:—, porque es la costura que la integración prueba. La segunda: es fresco por test —cada test recibe una conexión nueva, y al terminar se cierra—, un adelanto crudo del aislamiento que la lección 6 hará con rigor. Y en make_service, la mezcla de la tabla: FixedClock (reloj congelado, no determinista), StubPaymentGateway (pago doblado, externo), SpyEmailSender (correo doblado, externo), y el repo real que llega por parámetro. Real la costura, doblado el resto.
Ahora los dos flujos. El primero es la integración estrecha book→get: reservar y releer, verificando que la reserva cruzó la costura y volvió intacta.
def test_book_then_get_persists_the_booking(repo):
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
saved = repo.get(booking.id)
assert saved.price_cents == 6000
assert saved.start == START # datetime intacto (get lo reconstruye)
assert saved.status == "confirmed"
El segundo es el flujo completo book→cancel→get: reservar, cancelar, y releer el estado cancelado. Es el que ejercita el uso del dato leído —cancel no solo lee la reserva, la usa para calcular el reembolso—.
def test_book_cancel_get_full_flow_against_real_sqlite(repo):
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END) # escribe la reserva
refund = service.cancel(booking.id) # lee, calcula, guarda cancelada
assert refund == 6000 # reembolso completo (9 dias antes)
assert repo.get(booking.id).status == "cancelled" # el estado final, releido
El reloj está en CLOCK = 2026-03-01, nueve días antes del inicio de la reserva (START = 2026-03-10), así que la anticipación es de 216 horas, muy por encima de las 48 del primer tramo, y el reembolso esperado es el completo: 6000. Congelar el reloj es lo que convierte esa ancla en un dato fijo del test —si el reloj fuera real, el reembolso dependería de cuándo corras la prueba—.
Ejemplo trabajado: los dos flujos, en verde
Corramos la integración:
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_end_to_end_integration.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items
tests/test_end_to_end_integration.py::test_book_then_get_persists_the_booking PASSED [ 50%]
tests/test_end_to_end_integration.py::test_book_cancel_get_full_flow_against_real_sqlite PASSED [100%]
============================== 2 passed in 0.01s ==============================
Dos verdes, y con esto el tercer entregable existe. El primer test demuestra que una reserva creada por book se guarda en SQLite de verdad y vuelve intacta —price_cents == 6000, start como el datetime original, status == "confirmed"—. El segundo demuestra el ciclo completo: book escribe, cancel lee la reserva de vuelta, calcula el reembolso completo de 6000 (nueve días de anticipación), guarda el estado cancelado, y get confirma que quedó cancelled. Todo contra una base de datos real, cruzando la costura en cada paso. La vuelta a la pista salió limpia: BookingService y el SqliteBookingRepository no solo se parecen —colaboran—.
Por qué el flujo caza lo que el contrato no
Aquí está el valor único de este entregable, y merece desglosarse, porque explica por qué la integración no es redundante con el contrato aunque ambos toquen el mismo repositorio.
El segundo test ejercita algo que el contrato no puede: el uso del dato leído. cancel no se limita a recuperar la reserva; toma su start y lo resta de la hora del reloj para calcular la anticipación (refund_cents hace (booking.start - now)). Esa resta es aritmética de fechas, y solo funciona si start es un datetime. Si el SqliteBookingRepository.get tuviera el bug del módulo 1 —devolver el start como str—, este flujo reventaría con un TypeError: unsupported operand type(s) for -: 'str' and 'datetime.datetime', no en una aserción sino en el corazón de cancel.
Y esto es lo profundo: la integración cazaría ese bug aunque tu contrato tuviera un hueco justo ahí. El contrato inspecciona campos con ==; si tu cláusula 1 comparara solo status y price_cents y olvidara el start, el contrato pasaría en verde —el fake y el real coinciden en los campos que miraste—, y el bug del datetime viviría en el hueco. La integración no depende de que hayas pensado en verificar el start: no inspecciona, usa. cancel resta el start, y la resta revienta con el str, sin que ninguna aserción tuya tenga que apuntar al campo. La integración caza el bug por el uso, no por la inspección.
Esa es la complementariedad exacta que el capstone pide entregar completa: el contrato te protege de las divergencias que enumeraste; la integración te protege de las que no se te ocurrió enumerar pero que el flujo real desencadena. El contrato certifica que las piezas cumplen las cláusulas que escribiste; la integración verifica que colaboran de verdad, incluso en lo que no escribiste. Por eso los tres entregables son tres, y no dos: el contrato desde ambos lados es una capa, la integración de punta a punta es la otra, y ninguna hace innecesaria a la otra.
Estrecha y amplia: dos integraciones, un flujo
Nota que entregaste dos tests de integración, y no son lo mismo. El primero, book→get, es una integración estrecha: cruza la costura una vez en cada dirección (escribir, leer) y verifica que el dato sobrevive el viaje. El segundo, book→cancel→get, es una integración amplia: recorre un flujo de negocio completo —reservar, cancelar, releer— donde el dato escrito por un paso lo lee y lo usa el siguiente. La estrecha caza bugs de forma (el datetime que cambia de tipo al cruzar); la amplia caza además bugs de uso (el cancel que no puede operar sobre lo que leyó).
Las dos valen, y por eso las dos están en la entrega. Si solo tuvieras la estrecha, verificarías que el dato viaja bien pero no que BookingService puede hacer algo con él en un flujo real. Si solo tuvieras la amplia, cubrirías el uso pero perderías la verificación puntual de que un book seguido de un get conserva cada campo. Juntas dan la foto completa de la costura: el dato cruza intacto (estrecha) y el sistema lo usa bien en un flujo vivo (amplia). Elegir tener las dos es parte del método que el capstone evalúa.
Errores comunes
Doblar el repositorio "para que sea más rápido" y perder la integración. Qué pasa: alguien, por reflejo de unit test, usa FakeBookingRepository en la prueba de integración porque "corre más rápido". Por qué pasa: doblar todo es el hábito del testing unitario. Cómo detectarlo: si en tu integración ninguna pieza real cruza la costura, no probaste ninguna junta —es un unit test con nombre de integración—. Cómo corregirlo: la costura bajo prueba (el repositorio) va real, siempre. Lo que se dobla es lo externo y no determinista alrededor (pago, reloj, correo), no la costura misma. Con :memory: el repositorio real es casi tan rápido como el fake, así que la velocidad no es excusa para doblarlo.
Dejar el reloj real y tener un test que falla según el día. Qué pasa: alguien usa datetime.now() de verdad en la integración de cancel, y el reembolso sale distinto según cuándo corra el test. Por qué pasa: parece "más real" no doblar el reloj. Cómo detectarlo: si el test pasa hoy y falla mañana sin que el código cambie, dependes del tiempo. Cómo corregirlo: el reloj es no determinista y va doblado aunque el repositorio sea real. FixedClock(CLOCK) congela la hora, y eso convierte el reembolso en una función solo de los datos del test —6000 porque START está 216 horas después de CLOCK, no porque hoy sea tal día—. Real la costura, doblado lo no determinista: la regla no tiene excepción para el reloj.
Confundir "la integración pasó" con "el contrato es innecesario". Qué pasa: con los dos flujos en verde, alguien concluye que ya no hace falta el contrato. Por qué pasa: la integración toca el repositorio real y da mucha confianza. Cómo detectarlo: pregúntate si tus dos flujos ejercitan cada cláusula del contrato. El flujo feliz book→cancel→get nunca ejercita "get de un id ausente lanza" ni "find_by_room devuelve solo esa sala" —usa ids que existen y una sola sala—. Cómo corregirlo: mantén el contrato (cubre las cláusulas que la integración no recorre) y la integración (cubre el uso que el contrato no ejercita). Son capas distintas; el capstone pide las dos porque ninguna alcanza sola.
Ejercicios
Ejercicio 1 — Predice dónde explota. Sin correr nada, imagina que el SqliteBookingRepository.get tiene el bug del módulo 1 (devuelve start como str, sin fromisoformat). ¿Cuál de los dos tests de integración fallaría primero, con qué error, y en qué punto conceptual del flujo?
Ver solución
Fallaría test_book_cancel_get_full_flow_against_real_sqlite, el flujo amplio, con un TypeError. El punto exacto: cancel llama a refund_cents(booking, booking.price_cents, now), y dentro, refund_cents hace hours_until = (booking.start - now).total_seconds() / 3600. Con el bug, booking.start es el str '2026-03-10T09:00:00' y now es un datetime, así que la resta lanza TypeError: unsupported operand type(s) for -: 'str' and 'datetime.datetime'. El flujo revienta al usar el start, no al leerlo.
El primer test, test_book_then_get_persists_the_booking, también fallaría, pero por otra razón y de otra forma: su aserción saved.start == START daría False (str != datetime) y reportaría un AssertionError, no un TypeError. La diferencia es instructiva: el test estrecho caza el bug por inspección (compara el campo y ve que difiere); el amplio lo caza por uso (intenta operar con el campo y no puede). El amplio es el que se parece a lo que pasaría en producción —un cancel reventando—, y por eso su rojo es el más valioso.
Ejercicio 2 — Cambia el ancla del reembolso. El test verifica refund == 6000 con CLOCK = datetime(2026, 3, 1, 9). ¿Qué valor de CLOCK haría que el reembolso fuera 3000, y por qué? ¿Y para que fuera 0? Usa las anclas de Reservo (72 h→6000, 36 h→3000, 12 h→0).
Ver solución
refund_cents calcula hours_until = (booking.start - now) y aplica: >= 48 h → reembolso completo (6000); 24 <= hours_until < 48 → 50% (3000); < 24 → 0. Con START = 2026-03-10 09:00:
- Para
3000: hace falta una anticipación en[24, 48)horas, por ejemplo 36 horas. Eso esCLOCK = datetime(2026, 3, 8, 21)(36 h antes de las 9:00 del día 10). Entonceshours_until = 36, cae en24 <= 36 < 48, yrefund_centsdevuelve6000 * 50 // 100 = 3000. - Para
0: hace falta una anticipación menor a 24 horas, por ejemplo 12 horas. Eso esCLOCK = datetime(2026, 3, 9, 21)(12 h antes). Entonceshours_until = 12 < 24, yrefund_centsdevuelve0.
Lo que esto muestra: el FixedClock es lo que te deja elegir qué ancla ejerces, poniéndolo a la anticipación que quieras. Por eso el reloj se dobla aunque el repositorio sea real —es no determinista, y congelarlo convierte cada ancla en un dato del test—. Es la regla de la tabla en acción dentro del flujo de integración.
Ejercicio 3 — Estrecha contra amplia, en tu suite. Clasifica cada uno de estos tests de Reservo como integración estrecha o amplia, y di qué clase de bug caza cada uno: (a) book→get verificando que la reserva vuelve intacta; (b) book→cancel→get verificando el reembolso y el estado cancelado; (c) guardar directamente con repo.save y releer con un SELECT crudo sobre la tabla.
Ver solución
- (a)
book→get— estrecha. Cruza la costura una vez en cada dirección (escribir, leer) sin recorrer un flujo de negocio largo. Caza bugs de forma: que el dato sobreviva el viaje intacto (eldatetimeque no cambia de tipo, el precio que cruza como entero). Verifica por inspección: compara campo por campo lo que volvió. - (b)
book→cancel→get— amplia. Recorre un flujo de negocio completo donde el dato escrito por un paso lo lee y lo usa el siguiente (cancelresta elstart). Caza bugs de uso, además de los de forma: queBookingServicepueda de verdad operar sobre lo que el repositorio devuelve. Verifica por uso: ejercita el flujo y ve si algo revienta. - (c)
repo.save+SELECTcrudo — estrecha (y muy estrecha). Toca una sola pieza real (el repositorio) contra su recurso real (la tabla), sin pasar porBookingService. Caza bugs de forma en la costura misma: qué se guarda exactamente en la tabla, con una herramienta independiente del repositorio. Es la integración más estrecha posible —una pieza contra su frontera—.
La lección: una suite de integración sana mezcla estrechas (baratas, precisas, cazan forma) y amplias (recorren flujos reales, cazan uso). El capstone entrega al menos una de cada para cubrir las dos clases de bug de la costura.
Resumen y siguiente paso
En esta lección construiste el tercer entregable: la prueba de integración de punta a punta. Escribiste dos flujos contra el SqliteBookingRepository real —book→get, que verifica que una reserva cruza la costura y vuelve intacta, y book→cancel→get, que recorre el ciclo completo reservar-cancelar-releer— y los viste en verde. Aplicaste la tabla de la lección 1: real la costura que pruebas (el repositorio), doblado lo no determinista y lo externo (reloj, pago, correo). Con la vuelta a la pista del auto ensamblado entendiste que la integración prueba lo que ninguna prueba de banco puede —las piezas juntas y en movimiento— y, sobre todo, por qué caza lo que el contrato no: el flujo usa el dato (cancel resta el start) donde el contrato solo lo inspecciona, así que atrapa un bug de uso aunque el contrato tuviera un hueco justo ahí. Y distinguiste la integración estrecha (forma) de la amplia (uso), y por qué la entrega lleva las dos.
Antes de avanzar deberías poder: escribir una integración de punta a punta con la mezcla correcta de real y doblado, y justificar cada decisión; explicar por qué el flujo caza bugs de uso que la inspección del contrato no ve; y distinguir una integración estrecha de una amplia por la clase de bug que cada una atrapa.
La integración funciona, pero tiene una fragilidad que todavía no atendiste: usa un recurso real, y los recursos reales persisten. La fixture fresca por test que escribiste es un primer parche; falta hacerlo con rigor. En la lección 6 endureces el entregable 3 con el aislamiento del módulo 7: verás primero cómo un test contamina al siguiente cuando comparten estado real, y luego las dos curas —una fixture que crea y destruye una base efímera, y una transacción que se revierte por test— para que cada corrida empiece limpia.
Recursos
sqlite3— DB-API para SQLite (documentación de Python) — la referencia del recurso real que la integración cruza;Connection.execute,commityconnect(":memory:")son las piezas con las quebook,cancelygetoperan contra una base de verdad.datetime.fromisoformat— documentación de Python — la conversión que hace quecancelpueda restar elstart; sin ella, el flujo amplio reventaría con elTypeErrordel ejercicio 1.- Documentación de pytest — Cómo usar fixtures — la referencia de la fixture
repoque entrega un repositorio real fresco por test; el andamiaje que la lección 6 endurece con aislamiento. - Martin Fowler — IntegrationTest — el marco que explica por qué una integración verifica la colaboración real y caza bugs de uso que la verificación aislada de cada componente puede no enumerar; el fundamento de la complementariedad contrato/integración.