Módulo 5: Integración de verdad: componentes reales juntos
6. La integración caza lo que el unit no
Descripción
Este es el clímax del módulo, la lección donde cobras todo lo que construiste. Hasta ahora armaste integraciones con cuidado y las viste pasar en verde, dejando las aserciones dentro de lo que cruza la costura sin problema. Fue deliberado: querías ver la forma limpia de la herramienta. Ahora la herramienta va a trabajar. Vamos a llevar el flujo completo book→cancel→get contra el repositorio real, y vas a ver, con salida de pytest, cómo la integración caza un bug que el unit test, por más que corras, no puede atrapar —y que además un contrato con un hueco dejaría pasar—. El bug es viejo conocido: el datetime que SQLite devolvía como str —el que el módulo 1 expuso y el módulo 3 ya arregló con datetime.fromisoformat—. Aquí lo reintroducimos a propósito, por un momento, no para descubrirlo de nuevo, sino para mostrar un modo de fallo que ni el unit ni un contrato con un hueco verían venir. Y aquí no lo vas a ver como una aserción que falla; lo vas a ver como algo peor y más realista: un TypeError que revienta el flujo, porque cancel intenta usar ese str en una resta de fechas y no puede.
La diferencia con el módulo 1 es crucial y es la razón de ser de esta lección. En el módulo 1, el bug del datetime se veía como una aserción saved.start == START que daba False —lo notabas solo si mirabas el campo—. Un contrato que verifique el ida-y-vuelta con == lo caza, sí. Pero ¿y si tu contrato tuvo un hueco y solo verificó el status y el price_cents, olvidando el start? Entonces el contrato pasa en verde, el fake y el real "coinciden" en todo lo que el contrato mira, y el bug sigue vivo. Aquí es donde la integración muestra su poder único: no verifica la forma del dato inspeccionándolo, sino que ejercita su uso en un flujo real. cancel no mira el start: lo resta. Y un str no se puede restar de un datetime. El flujo completo explota donde ni el unit (que usa el fake, con un datetime de verdad) ni un contrato incompleto (que no miró el start) lo verían venir.
Conexión con el módulo: esta lección es la recompensa hacia la que apuntaron todas las anteriores. La 1 prometió que ibas a ver la integración funcionando; la 2 la definió; la 3 la hizo tangible; la 4 y la 5 te enseñaron a montarla bien y a nombrarla (aquí comparamos el book→cancel→get solitario con el sociable). Ahora ves por qué vale la pena: la integración caza una clase de bug —los de uso en la colaboración real— que ninguna otra herramienta ve completa. Y cierra recordando el arreglo del módulo 3, para que no te quedes solo con el rojo: datetime.fromisoformat en get, y el flujo book→cancel→get en verde. La lección 7 pondrá el costo de todo esto sobre la mesa; esta pone el beneficio.
Analogía: la llave que entra pero no gira
Piensa en una llave nueva que mandaste a copiar. La revisas y se ve idéntica a la original: los dientes en su sitio, el mismo perfil, el mismo grosor. Si la inspeccionas —la comparas con la original al ojo—, pasa: "es una copia correcta". Eso es lo que hace un contrato que verifica la forma del dato: mira la reserva que vuelve y comprueba campo por campo que se ve bien. Pero una llave no existe para verse bien; existe para girar en la cerradura. Y hay copias que se ven perfectas y, cuando las metes en la cerradura de verdad y tratas de girar, se atascan —un diente medio milímetro más alto, un ángulo mínimamente torcido que el ojo no capta pero la cerradura sí—. La inspección visual jamás lo habría dicho; solo usar la llave en la cerradura real lo revela.
Una prueba de integración es meter la llave en la cerradura y girar. No inspecciona la reserva que vuelve del repositorio; la usa en el flujo real —cancel la lee y trata de restar su start—. El str que devuelve SQLite es la copia que se ve perfecta (tiene el valor correcto, '2026-03-10T09:00:00', la fecha bien) pero no gira: cuando cancel intenta la resta booking.start - now, la cerradura se atasca con un TypeError. Un unit test con el fake usa la llave original (un datetime de verdad, que gira perfecto). Un contrato que solo inspecciona algunos campos puede aprobar la copia sin meterla nunca en la cerradura. La integración es la única que mete la llave real en la cerradura real y descubre que no gira.
El flujo completo: solitario verde, sociable rojo
Montemos el experimento. Dos tests del mismo flujo book→cancel→get. El primero es solitario: todos los vecinos doblados, el repositorio es el FakeBookingRepository. El segundo es sociable: el mismo flujo, con el SqliteBookingRepository real. La única diferencia es qué pieza está en la costura del repositorio —el experimento controlado de la lección 5, ahora sobre el flujo completo—. El reloj está en CLOCK = 2026-03-01, nueve días antes del inicio de la reserva, así que el reembolso esperado es el completo, 6000.
Un aviso antes de correr: en el módulo 3 ya arreglaste el SqliteBookingRepository —su get reconstruye el datetime con datetime.fromisoformat—, así que este flujo, contra el repositorio arreglado, correría en verde. Para revivir la escena y ver el fallo, en este experimento revertimos por un momento aquel arreglo: dejamos el get tal como estaba en el módulo 1, devolviendo el start como str sin reconstruirlo. Es el mismo bug de siempre, traído de vuelta a propósito para mostrar cómo se manifiesta en el uso:
# reservo/sqlite_repo.py — get() revertido al bug del modulo 1 (solo para este experimento)
def get(self, booking_id):
row = self._conn.execute(
"SELECT id, room_id, member_id, start, end, status, price_cents "
"FROM bookings WHERE id = ?",
(booking_id,),
).fetchone()
if row is None:
raise KeyError(booking_id)
return Booking(
id=row[0], room_id=row[1], member_id=row[2],
start=row[3], end=row[4], # BUG del modulo 1: sale como str, no datetime
status=row[5], price_cents=row[6],
)
Con ese get roto puesto de vuelta, montamos el experimento.
# tests/test_book_cancel_get.py — el flujo completo book -> cancel -> get
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) # Focus 3 h
CLOCK = datetime(2026, 3, 1, 9) # 9 dias antes -> reembolso completo (6000)
def make_service(repo):
return BookingService(
Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo,
)
# unit: todo el flujo contra el fake en memoria
def test_book_cancel_get_with_fake_repo():
repo = FakeBookingRepository()
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
refund = service.cancel(booking.id)
assert refund == 6000
assert repo.get(booking.id).status == "cancelled"
# integracion: el mismo flujo contra SQLite real
def test_book_cancel_get_with_sqlite_repo():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
refund = service.cancel(booking.id) # <-- cancel lee el start de vuelta
assert refund == 6000
assert repo.get(booking.id).status == "cancelled"
Léelos: son gemelos. Mismo book, mismo cancel, mismas dos aserciones. Lo único que cambia es el repositorio. Si "leer una reserva y usarla" fuera lo mismo con los dos, darían el mismo resultado. Corramos.
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_book_cancel_get.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items
tests/test_book_cancel_get.py::test_book_cancel_get_with_fake_repo PASSED [ 50%]
tests/test_book_cancel_get.py::test_book_cancel_get_with_sqlite_repo FAILED [100%]
=================================== FAILURES ===================================
____________________ test_book_cancel_get_with_sqlite_repo _____________________
def test_book_cancel_get_with_sqlite_repo():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
> refund = service.cancel(booking.id) # <-- cancel lee el start de vuelta
tests/test_book_cancel_get.py:44:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
reservo/services.py:40: in cancel
refund = refund_cents(booking, booking.price_cents, now) # calcula
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
booking = Booking(id='bk-m-ana-...', room_id='focus', member_id='m-ana', start='2026-03-10T09:00:00', end='2026-03-10T12:00:00', status='confirmed', price_cents=6000)
price_paid_cents = 6000, now = datetime.datetime(2026, 3, 1, 9, 0)
def refund_cents(booking, price_paid_cents, now):
"""Reembolso segun la anticipacion desde now hasta booking.start."""
> hours_until = (booking.start - now).total_seconds() / 3600
E TypeError: unsupported operand type(s) for -: 'str' and 'datetime.datetime'
reservo/pricing.py:11: TypeError
========================= 1 failed, 1 passed in 0.05s ==========================
Ahí está la recompensa, sin retórica. El flujo solitario con el fake pasa; el sociable con el real falla, y no en una aserción sino en el corazón de cancel. Lee la traza de abajo hacia arriba: service.cancel(booking.id) llama a refund_cents(booking, booking.price_cents, now), y ahí explota hours_until = (booking.start - now) con un TypeError: unsupported operand type(s) for -: 'str' and 'datetime.datetime'. Mira los valores que pytest te muestra: booking.start es '2026-03-10T09:00:00' —un str, entre comillas— y now es datetime.datetime(2026, 3, 1, 9, 0) —un datetime—. No se pueden restar. El cancel leyó del repositorio real una reserva cuyo start volvió como texto, y al intentar hacer aritmética de fechas con él, el flujo se rompió.
Por qué ni el unit ni un contrato incompleto lo ven
Esto es lo que hace especial a la integración, y merece desglosarse.
El unit test no lo ve, porque usa el fake. El FakeBookingRepository guarda el objeto entero en un dict y lo devuelve intacto: booking.start sale como el datetime original. Cuando cancel hace booking.start - now, es datetime - datetime, que funciona perfecto, y el reembolso sale 6000. El test solitario pasa en verde, y no está mal —es cierto para el fake—. Pero el fake es la llave original: gira siempre. Ningún test que use solo el fake puede descubrir que la copia real no gira.
Un contrato completo sí lo caza; uno incompleto, no. El contrato de los módulos 3 y 4 verifica el ida-y-vuelta del repositorio. Si tu cláusula es repo.get(id) == booking —comparando el objeto entero—, caza el start como str, porque un Booking con start='...' (texto) no es igual a uno con start=datetime(...). Perfecto. Pero los contratos los escribes tú, y tienen exactamente los huecos que dejes. Si tu cláusula fue más floja —"el status y el price_cents sobreviven el ida-y-vuelta", olvidando el start—, el contrato pasa en verde: el fake y el real coinciden en status y price_cents. El bug del datetime vive en el hueco de tu contrato, invisible.
La integración lo caza aunque el contrato tuviera ese hueco. Y esta es la clave: la integración no depende de que hayas pensado en verificar el start. No inspecciona campos; ejercita el flujo. cancel usa el start en una resta, y esa resta revienta con el str, sin que ninguna aserción tuya tenga que apuntar al start. La integración caza el bug por el uso, no por la inspección. Por eso una integración del flujo completo es un complemento irremplazable del contrato: 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.
Es la moraleja del módulo entero: 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. Necesitas las dos.
El arreglo, y el flujo en verde
No te quedes con el rojo. El arreglo no es nuevo: es el mismo que ya aplicaste en el módulo 3, el que hace fiel al provider. Recuerda el bug del datetime que la integración del módulo 1 expuso y que el módulo 3 arregló: el SqliteBookingRepository.get convierte el texto de vuelta a datetime al leer, con datetime.fromisoformat, para que la reserva que devuelve cumpla el mismo contrato que el fake —un start que es un datetime de verdad—. Deshacemos el rollback de arriba y el flujo vuelve al verde. Así es el get arreglado, como recordatorio:
# reservo/sqlite_repo.py — get() arreglado
from datetime import datetime
# ...
def get(self, booking_id):
row = self._conn.execute(
"SELECT id, room_id, member_id, start, end, status, price_cents "
"FROM bookings WHERE id = ?",
(booking_id,),
).fetchone()
if row is None:
raise KeyError(booking_id)
return Booking(
id=row[0], room_id=row[1], member_id=row[2],
start=datetime.fromisoformat(row[3]), # texto -> datetime de vuelta
end=datetime.fromisoformat(row[4]),
status=row[5], price_cents=row[6],
)
Con get devolviendo start y end como datetime, cancel puede restar sin problema. Corramos el flujo completo book→cancel→get contra este repositorio arreglado:
# tests/test_book_cancel_get_fixed.py — el flujo completo contra el repo ARREGLADO
def test_book_cancel_get_full_flow_against_real_sqlite():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = BookingService(Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), 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
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_book_cancel_get_fixed.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item
tests/test_book_cancel_get_fixed.py::test_book_cancel_get_full_flow_against_real_sqlite PASSED [100%]
============================== 1 passed in 0.01s ===============================
Verde. El flujo completo book→cancel→get corre de punta a punta contra SQLite de verdad: book escribe la reserva, cancel la lee de vuelta (ahora con el start como datetime), calcula el reembolso completo de 6000 (nueve días de anticipación), guarda el estado cancelado, y get confirma que quedó cancelled. La llave copiada ahora gira: la integración que antes reventaba con un TypeError pasa limpia, porque el proveedor cumple el contrato que su consumidor necesita. Fíjate en el ciclo completo que esto demuestra —escribir, leer, recalcular sobre lo leído, reescribir, releer— todo contra una base de datos real. Ese es un flujo de integración amplio, y verlo en verde es la señal de que BookingService y SqliteBookingRepository de verdad colaboran, no solo se parecen.
Errores comunes
Arreglar el bug haciendo que el fake también devuelva un str. Qué pasa: alguien, para que el unit test "refleje" el bug, hace que FakeBookingRepository.get devuelva el start como texto. Por qué pasa: parece que alinear el fake al real cierra la divergencia. Cómo detectarlo: si tu fake ahora reproduce un comportamiento indeseable del real, horneaste el bug en el doble en vez de arreglarlo. Cómo corregirlo: la divergencia se cierra decidiendo el comportamiento correcto —get debe devolver un datetime— y haciendo que ambos proveedores lo cumplan. El fake ya lo cumple; el real se arregla con datetime.fromisoformat. El fake no debe imitar los defectos del real; los dos deben cumplir un contrato correcto.
Creer que un contrato verde hace innecesaria la integración del flujo. Qué pasa: alguien tiene el contrato del repositorio en verde y concluye que el flujo book→cancel→get ya está cubierto. Por qué pasa: un contrato verde da mucha confianza. Cómo detectarlo: pregúntate si tu contrato verifica cada campo del ida-y-vuelta y cada uso que el servicio hace del dato. Si el contrato tiene un hueco (no verificó el start), y ningún test ejercita el flujo real, el bug del datetime vive en ese hueco. Cómo corregirlo: manten el contrato (te protege de lo que enumeraste) y añade una integración del flujo (te protege de lo que el uso real desencadena aunque no lo enumeraras). Son complementarios; ninguno hace innecesario al otro.
Leer el TypeError como "un bug del test" en vez de "un bug que el test encontró". Qué pasa: sale el TypeError y alguien dice "el test de integración está mal escrito, arréglalo para que pase". Por qué pasa: un test que revienta parece un test roto. Cómo detectarlo: mira dónde explota la traza. Si el error está en el código de producción (reservo/pricing.py, dentro de cancel), el test no está mal: encontró un bug real que en producción también reventaría. Cómo corregirlo: el arreglo no es tocar el test, es arreglar el proveedor (get que convierte de vuelta). El test hizo justo su trabajo —cazar, antes de producción, un TypeError que el usuario habría sufrido al cancelar—. Cambiar el test para silenciarlo sería tapar el bug.
Ejercicios
Ejercicio 1 — Predice dónde explota. Sin correr nada, imagina que en vez de cancel, el flujo fuera book→get y luego intentaras saved.end - saved.start para calcular la duración, con el repositorio real (buggy, sin el arreglo). ¿Pasaría o fallaría? Si falla, ¿con qué error y en qué línea conceptual?
Ver solución
Fallaría, con un TypeError del mismo tipo. Con el repositorio buggy, get devuelve saved.start y saved.end como str (texto ISO). Al intentar saved.end - saved.start, estarías restando str - str, y Python no define la resta entre cadenas: lanzaría TypeError: unsupported operand type(s) for -: 'str' and 'str'.
Es el mismo mecanismo de la lección, con una variante: aquí los dos operandos son str (ambos vienen del repositorio), mientras que en cancel era str - datetime (el start del repositorio menos el now del reloj, que sí es un datetime). En los dos casos, la causa es la misma —el repositorio devuelve texto donde el código espera un datetime— y el síntoma es el mismo —el flujo revienta al usar el valor, no al inspeccionarlo—. Y en los dos casos, el arreglo es el mismo: que get convierta de vuelta con datetime.fromisoformat, para que los campos vuelvan como datetime y la aritmética funcione.
Ejercicio 2 — El contrato con el hueco. Escribe una cláusula de contrato del repositorio que pasaría en verde para el fake y el real buggy (es decir, que tiene el hueco), y explica por qué el flujo book→cancel→get cazaría el bug que esa cláusula no ve.
Ver solución
Una cláusula con el hueco verificaría solo los campos que cruzan la costura sin cambiar de forma:
def test_save_then_get_preserves_status_and_price(repo):
booking = a_booking() # status="confirmed", price_cents=6000
repo.save(booking)
got = repo.get(booking.id)
assert got.status == "confirmed" # texto: sobrevive
assert got.price_cents == 6000 # entero: sobrevive
# (NO verifica got.start ni got.end) <-- el hueco
Esta cláusula pasa en verde para el fake y para el real buggy, porque status (texto) y price_cents (entero) tienen tipo nativo en SQLite y cruzan la costura idénticos en ambos. El contrato queda "verde", dando la falsa impresión de que el fake y el real coinciden. Pero nunca miró el start, que es justo donde divergen.
El flujo book→cancel→get caza el bug que esta cláusula no ve porque no depende de que la cláusula haya mirado el start. cancel usa el start en una resta, y esa resta revienta con el str, sin que ninguna aserción tenga que apuntar al start. La cláusula floja no lo vio porque solo inspeccionó dos campos; la integración lo ve porque ejercita el uso real del tercero. Ahí está la complementariedad: el contrato cubre lo que enumeras, la integración cubre lo que el flujo desencadena. Un contrato con huecos necesita una integración que ejercite el uso para taparlos.
Ejercicio 3 — ¿Por qué refund == 6000 y no otra ancla? En el flujo arreglado, el test verifica refund == 6000. Explica de dónde sale ese número dado CLOCK = datetime(2026, 3, 1, 9) y START = datetime(2026, 3, 10, 9), y qué habría que cambiar para verificar el ancla de 3000.
Ver solución
El reembolso es 6000 porque la anticipación es enorme. refund_cents calcula hours_until = (booking.start - now). Con now = CLOCK = 2026-03-01 09:00 y booking.start = 2026-03-10 09:00, la diferencia es exactamente nueve días, es decir 216 horas. Como 216 >= 48, cae en el primer tramo de la política (hours_until >= 48 → reembolso completo), así que devuelve price_paid_cents, que es 6000. Es el ancla de reembolso total.
Para verificar el ancla de 3000 (el 50%, tramo 24 <= hours_until < 48), habría que poner el reloj a una anticipación dentro de ese rango —por ejemplo, 36 horas antes del inicio—. Con START = 2026-03-10 09:00, eso sería CLOCK = datetime(2026, 3, 8, 21) (36 horas antes de las 9:00 del día 10). Entonces hours_until = 36, que cae en 24 <= 36 < 48, y refund_cents devolvería 6000 * 50 // 100 = 3000. El test verificaría refund == 3000. (Y para el ancla de 0, un reloj a menos de 24 h del inicio, como 12 horas antes.)
Lo que esto muestra: en una integración del flujo de cancel, el reloj doblado (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 lección 4 en acción —real la costura (repositorio), doblado lo no determinista (reloj)— dentro del flujo que caza el bug.
Resumen y siguiente paso
En esta lección cobraste la recompensa del módulo. Llevaste el flujo completo book→cancel→get contra el repositorio real y viste, con salida de pytest, cómo la integración caza lo que el unit no: el flujo solitario con el fake pasa en verde, el sociable con SQLite revienta con un TypeError: unsupported operand type(s) for -: 'str' and 'datetime.datetime', porque cancel intentó restar un start que volvió como texto. Con la llave que entra pero no gira entendiste la diferencia entre inspeccionar la forma de un dato (lo que hace un contrato) y usarlo en el flujo real (lo que hace una integración), y por qué un contrato con un hueco —que no miró el start— dejaría pasar un bug que el flujo caza por el uso. Y no te quedaste con el rojo: restauraste el arreglo del módulo 3 (datetime.fromisoformat en get) y viste el flujo completo en verde.
Antes de avanzar deberías poder: explicar por qué el unit test con el fake es ciego a este bug; explicar por qué la integración lo caza aunque un contrato incompleto no lo viera (uso contra inspección); y aplicar el arreglo en el proveedor sin caer en el falso arreglo de hacer que el fake imite el defecto.
Ya viste el beneficio de la integración en todo su esplendor. Toca ver la factura. En la lección 7 ponemos números reales sobre la mesa: cuánto más lento es de verdad tocar SQLite que un fake —lo mediremos con el fake contra SQLite en memoria contra SQLite en disco— y qué carga añade sembrar y limpiar la base de datos en cada test. Con ese costo en la mano, sabrás decidir cuándo pagar por una integración y cuándo un contrato o un unit basta, que es el criterio que cierra el módulo.
Recursos
datetime.fromisoformat— documentación de Python — la función que reconstruye undatetimedesde el texto ISO que devuelve SQLite; el arreglo exacto que ponegeta cumplir el contrato y el flujobook→cancel→geten verde.sqlite3— Adaptadores y conversores de tipos (documentación de Python) — la sección que explica por qué SQLite no guarda undatetimecomo tal y cómo registrar la conversión de vuelta de forma automática, una alternativa alfromisoformatmanual del arreglo.- Documentación de pytest — Cómo entender los reportes de fallo — para leer el bloque de
FAILURESy seguir la traza que va deservice.cancelarefund_centshasta elTypeError, como en el ejemplo trabajado. - 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 de esta lección.