Módulo 1: De la unidad a la integración: por qué
5. Un unit test verde puede esconder una integración rota
Descripción
Llegamos al corazón de la guía. Todo lo anterior —las definiciones, la pirámide, las costuras— fue para llegar preparado a una sola frase: un unit test verde puede esconder una integración rota. No "a veces la suite tiene huecos", no "hay que probar más": algo más incómodo y más preciso. Tu unit test puede pasar, con toda razón, sobre un código que en producción está roto —y el test no tiene forma de saberlo, porque el problema vive exactamente donde el test dejó de mirar—.
La causa siempre es la misma: el unit test cierra una costura con un doble, y el doble diverge del proveedor real en esa costura. El test verifica que tu lógica es correcta suponiendo que el doble representa fielmente a lo real. Si esa suposición falla, el verde es verdadero para el doble y falso para el mundo. En la lección 1 lo viste con book; aquí lo vamos a aislar hasta su forma más pura —una sola costura, una sola operación, save seguido de get— para que veas el mecanismo sin ruido. El fake y el SqliteBookingRepository real reciben la misma reserva, se les pide la misma reserva de vuelta, y se les hace la misma pregunta. Uno pasa. El otro falla. Y la diferencia no es un bug de tu lógica: es una divergencia en la costura que el doble, por su propia naturaleza, no podía revelar.
Conexión con el módulo: esta lección es el porqué destilado de todo el módulo. La 2 te dio las definiciones, la 3 la proporción, la 4 el lugar (la costura); aquí ves el fallo concreto que justifica que la integración exista. No cerramos la brecha todavía —el cómo sistemático es el contrato de los módulos 3 y 4, y la integración a fondo es del 5 al 7—. Lo que esta lección instala es la conciencia: nunca más vas a mirar una suite verde de puros unit tests y concluir, sin más, que el sistema funciona. Vas a preguntar: ¿qué costuras están dobladas, y qué tan seguro estoy de que mis dobles no mienten justo ahí?
Analogía: el doble de riesgo que ensayó con una piscina de red
Piensa en una película de acción con una escena peligrosa: un actor debe caer desde una azotea. No lo hace el actor; lo hace un doble de riesgo (un stunt), y antes de rodar, el doble ensaya. Pero ensaya cayendo sobre una piscina de red elástica montada en el set de práctica, no sobre las cajas de cartón que habrá el día del rodaje real. En los ensayos, la caída sale perfecta: aterriza limpio, se levanta, sonríe. Cien ensayos, cien éxitos, todos en verde. El día del rodaje, cae sobre las cajas reales —que se comprimen distinto, se desplazan, tienen un borde más duro— y se tuerce el tobillo. Los cien ensayos exitosos no mintieron sobre el ensayo: el doble de verdad cae bien sobre la red. Mintieron sobre lo que importaba —la caída sobre lo real— porque ensayó contra algo distinto de aquello a lo que se enfrentaría.
Tu unit test es ese ensayo, y el doble de prueba es la piscina de red. El test verifica, con toda honestidad, que tu código "cae bien" sobre el doble: book guarda la reserva en el dict, la lee de vuelta, y el datetime está intacto. Cien corridas, cien verdes. Pero producción no usa el dict; usa SQLite, que "amortigua distinto": serializa el datetime a texto y te lo devuelve como str. El código que caía perfecto sobre la red se tuerce el tobillo sobre las cajas reales. El verde no mintió sobre el ensayo; mintió sobre el estreno, porque ensayaste contra una piscina de red que no se comporta como el suelo real. Un test de integración es ensayar la caída sobre las cajas de verdad, aunque sea una sola vez, antes del día del rodaje.
Ejemplo trabajado: la divergencia, en su forma más pura
Reduzcamos el problema a su mínimo. Sin BookingService, sin cobros, sin correos: solo el repositorio, una reserva, y la operación más básica que existe —guardarla y volver a leerla—. Los dos tests hacen exactamente lo mismo, con exactamente las mismas aserciones; lo único que cambia es qué proveedor está detrás de la costura.
# tests/test_repo_roundtrip.py — el mismo ida-y-vuelta, dos proveedores
import sqlite3
from datetime import datetime
from reservo.doubles import FakeBookingRepository
from reservo.models import Booking
from reservo.sqlite_repo import SqliteBookingRepository
START = datetime(2026, 3, 10, 9)
END = datetime(2026, 3, 10, 12)
def a_booking():
return Booking(id="bk-1", room_id="focus", member_id="m-ana",
start=START, end=END, status="confirmed", price_cents=6000)
# unit: contra el doble
def test_fake_roundtrips_a_saved_booking():
repo = FakeBookingRepository()
repo.save(a_booking())
got = repo.get("bk-1")
assert got.price_cents == 6000
assert got.start == START # datetime == datetime
# integracion: contra el repositorio real
def test_sqlite_roundtrips_a_saved_booking():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
repo.save(a_booking())
got = repo.get("bk-1")
assert got.price_cents == 6000
assert got.start == START # <-- str != datetime
Léelos con cuidado: son gemelos. Misma reserva de partida, mismo save, mismo get("bk-1"), mismas dos aserciones. Si "guardar y leer" fuera de verdad lo mismo en los dos proveedores, los dos tests darían el mismo resultado. Corramos y veamos.
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_repo_roundtrip.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items
tests/test_repo_roundtrip.py::test_fake_roundtrips_a_saved_booking PASSED [ 50%]
tests/test_repo_roundtrip.py::test_sqlite_roundtrips_a_saved_booking FAILED [100%]
=================================== FAILURES ===================================
____________________ test_sqlite_roundtrips_a_saved_booking ____________________
def test_sqlite_roundtrips_a_saved_booking():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
repo.save(a_booking())
got = repo.get("bk-1")
assert got.price_cents == 6000
> assert got.start == START # <-- str != datetime
E AssertionError: assert '2026-03-10T09:00:00' == datetime.datetime(2026, 3, 10, 9, 0)
E + where '2026-03-10T09:00:00' = Booking(id='bk-1', ..., start='2026-03-10T09:00:00', ...).start
tests/test_repo_roundtrip.py:35: AssertionError
========================= 1 failed, 1 passed in 0.02s ==========================
Ahí está el mecanismo, desnudo. El test contra el fake pasa; el test contra el real falla, en la línea del datetime. La misma pregunta, dos respuestas: assert got.start == START es True con el fake y False con SQLite. Y fíjate en la aserción que no falló: got.price_cents == 6000 pasa en ambos. La divergencia no está en todo el objeto —está en un campo, el datetime, precisamente el que la costura tiene que serializar—. El fake nunca serializa: guarda el objeto entero en un dict, así que get te devuelve el mismísimo objeto, con su datetime intacto. SQLite tiene que meter el dato en una tabla de texto y números; el datetime no cabe como tal, se guarda como texto ISO, y vuelve como str. El fake y el real no hacen lo mismo en save/get, y esa diferencia es invisible para cualquier test que solo use el fake.
Anatomía del engaño: por qué el verde es verdadero y engañoso
Lo más importante de entender es que el unit test verde no está mal. test_fake_roundtrips_a_saved_booking pasa porque es cierto: el FakeBookingRepository de verdad devuelve el datetime intacto. El test no tiene un bug; hace exactamente lo que promete —verificar el ida-y-vuelta contra el fake—. El engaño no está en el test; está en la conclusión que sacamos de él. Vemos el verde y pensamos "guardar y leer una reserva funciona". Lo que el verde de verdad dice es más estrecho: "guardar y leer una reserva funciona con este doble". El salto de lo segundo a lo primero —de "funciona con el doble" a "funciona"— es el engaño, y lo damos sin darnos cuenta porque el doble se parece tanto a lo real que olvidamos que no lo es.
Desglosemos la cadena que produce el falso sentido de seguridad:
- El doble encarna una suposición. Cuando escribiste
FakeBookingRepository.getpara que devolviera el objeto guardado tal cual, codificaste una suposición: "leer una reserva la devuelve idéntica a como se guardó". Es razonable. Y para SQLite, es falsa. - El unit test hereda la suposición. Al probar contra el fake, el test no verifica esa suposición: la da por buena. No puede hacer otra cosa —el fake es la suposición hecha objeto—. Un test no puede atrapar un error que vive en su propia premisa.
- La conclusión salta la costura. Interpretamos el verde como una afirmación sobre el sistema real, cuando solo es una afirmación sobre el sistema doblado. La costura que el doble cerró es exactamente la costura sobre la que el test guarda silencio.
Por eso ninguna cantidad de unit tests cierra esta brecha. Podrías tener mil tests del ida-y-vuelta contra el fake, con mil reservas distintas, y los mil pasarían, y los mil compartirían la misma premisa falsa. El error no se diluye con el volumen porque no es un error de este test: es un límite de todos los tests que doblan esta costura. La única forma de ver la divergencia es dejar de doblar —cruzar la costura con la pieza real—, y eso es, por definición, un test de integración.
La lección que te llevas: sospecha de la costura, no del verde
No saques la conclusión pesimista de que "los unit tests mienten" o "los dobles son malos". Los unit tests y los dobles son indispensables —son la base ancha de la pirámide, tu velocidad y tu precisión—. La conclusión correcta es más quirúrgica: un verde de unit test es una afirmación sobre el doble, no sobre lo real, y por cada costura que doblas queda una pregunta abierta que ese verde no responde: "¿mi doble se comporta como el proveedor real en esta costura?".
Esa pregunta tiene dos respuestas posibles según el riesgo de la costura. Para costuras baratas y sin frontera —el Calendar en memoria—, la respuesta es "el doble y lo real son la misma cosa, no hay divergencia posible", y no hace falta nada más. Para costuras de frontera con serialización, red o disco —el repositorio, el pago, el correo—, la respuesta honesta es "no lo sé hasta que lo verifique contra lo real", y ahí necesitas o un test de integración (cruzar la costura con la pieza real) o un contrato (una batería que ambos lados deben pasar). La guía te dará las dos herramientas. Lo que esta lección te deja es el reflejo de hacerte la pregunta: ver una suite verde y, en vez de relajarte, mirar el mapa de costuras y señalar cuáles siguen sin verificar contra el mundo.
Errores comunes
Leer "verde" como "el sistema funciona". Qué pasa: la suite unitaria pasa entera y alguien despliega con confianza total. Por qué pasa: el verde es una señal tan fuerte y tan satisfactoria que borra la letra chica de qué fue lo que se verificó. Cómo detectarlo: pregúntate "¿este verde afirma algo sobre la pieza real, o sobre un doble?". Si todos tus verdes hablan de dobles en las costuras de frontera, no tienes evidencia sobre el sistema real. Cómo corregirlo: traduce cada verde a su afirmación estrecha —"funciona con el doble"— y fíjate cuáles costuras quedan sin un verde que hable de lo real. Esas son tus integraciones pendientes.
Culpar al unit test cuando el bug aparece en producción. Qué pasa: estalla el datetime en producción y alguien dice "el unit test estaba mal escrito". Por qué pasa: si el test no atrapó el bug, parece que el test falló. Cómo detectarlo: revisa qué probaba el test. Si probaba honestamente el ida-y-vuelta contra el fake, no está mal: está incompleto a nivel de suite, no equivocado a nivel de test. Cómo corregirlo: el arreglo no es "reescribir el unit test", es "añadir el test de integración que faltaba". El unit test seguirá dándote velocidad y precisión sobre la lógica; el de integración cubrirá la costura que aquél, por diseño, no podía ver.
"Arreglar" el bug haciendo que el fake mienta igual que el real. Qué pasa: alguien, para que el unit test refleje el bug del datetime, hace que el FakeBookingRepository.get también devuelva un str. Por qué pasa: parece que "alinear el fake con el real" resuelve la divergencia. Cómo detectarlo: si tu fake ahora reproduce un comportamiento indeseable del real, estás horneando el bug en tu doble en vez de arreglarlo. Cómo corregirlo: la divergencia se cierra decidiendo cuál es el comportamiento correcto (probablemente get debería devolver un datetime, arreglando SqliteBookingRepository para que convierta de vuelta) y haciendo que ambos proveedores lo cumplan —eso es lo que un contrato fuerza—. El fake no debe imitar los defectos del real; ambos deben cumplir un contrato correcto, y el contrato es quien lo garantiza. Es exactamente el trabajo de los módulos 3 y 4.
Ejercicios
Ejercicio 1 — Traduce el verde. El test test_fake_roundtrips_a_saved_booking pasa. Escribe la afirmación estrecha que ese verde de verdad garantiza, y luego la afirmación amplia que sería un error deducir de él. Explica qué costura separa a las dos.
Ver solución
- Afirmación estrecha (lo que el verde garantiza): "Guardar una reserva con
FakeBookingRepository.savey leerla conFakeBookingRepository.getdevuelve una reserva con el mismoprice_centsy el mismostartque se guardó." Es cierta, y es todo lo que el test probó. - Afirmación amplia (el error de deducción): "Guardar y leer una reserva conserva sus campos" —a secas, para cualquier repositorio, incluido el de producción—. Esta es falsa (SQLite devuelve
startcomostr), y el verde no la respalda.
Lo que separa a las dos es la costura del repositorio: la afirmación estrecha vive del lado del doble; la amplia salta al lado del proveedor real. El verde solo autoriza a decir cosas del lado que el test tocó (el fake). Cada vez que un verde tienta a decir algo del otro lado de una costura doblada, estás dando el salto que esta lección advierte.
Ejercicio 2 — Otra divergencia en la misma costura. Además del datetime, imagina que Reservo empieza a guardar reservas con un campo notes que puede ser None. El FakeBookingRepository guarda el objeto tal cual, así que notes=None vuelve como None. Pero un compañero configura la columna SQLite como notes TEXT NOT NULL. Sin correr nada, predice: ¿qué unit test pasaría y qué test de integración fallaría, y en qué momento exacto?
Ver solución
El unit test pasaría. Un test que guarde una reserva con notes=None contra el FakeBookingRepository y verifique got.notes is None pasa en verde: el fake guarda el objeto entero en el dict, así que notes=None vuelve como None, sin que la restricción NOT NULL exista siquiera en el mundo del fake.
El test de integración fallaría, y antes de lo que crees: no en la aserción, sino en el save. Al intentar INSERT de una fila con notes = NULL en una columna NOT NULL, SQLite rechaza la operación y lanza una IntegrityError. Así que el test de integración explota en la línea repo.save(...), no en repo.get(...) ni en el assert. La divergencia aquí no es de forma del dato (como el datetime), sino de reglas que solo el proveedor real impone: el fake acepta cualquier objeto, la base de datos real hace cumplir su esquema.
La moraleja refuerza la lección: la misma costura (el repositorio) puede divergir de muchas maneras —tipos que cambian, restricciones que solo lo real impone, ids que colisionan—, y un doble, por cómodo que sea, no conoce ninguna de esas reglas a menos que tú se las enseñes. Solo la pieza real las trae puestas.
Ejercicio 3 — El plan para no volver a caer. Tu equipo escarmentó con el datetime. Un compañero propone: "de ahora en adelante, prohibamos los fakes y probemos todo contra SQLite real, así nunca se nos escapa una divergencia". Critica la propuesta usando lo que sabes de las lecciones 2 y 3, y propón un plan mejor.
Ver solución
La propuesta cambia un problema por otro peor. Prohibir los fakes y probar todo contra SQLite real invierte la pirámide (lección 3): la suite se vuelve lenta (cada test abre y toca una base de datos), frágil (falla por el estado real, no por tu lógica) y ambigua al fallar (¿fue la lógica o la base de datos?). Además pierde la precisión del unit test: cuando algo se rompa, ya no sabrás si el bug está en tu orquestación o en la persistencia. Es el cono de helado, motivado por un susto legítimo pero resuelto en la dirección equivocada.
El plan mejor mantiene la pirámide y cierra la brecha:
- Conserva la base ancha de unit tests con fakes para la lógica: rápida, precisa, la mayor parte de tu confianza sobre
book/cancel/precios/reembolsos. - Añade una franja de tests de integración que crucen la costura del repositorio con SQLite real —unos pocos, no uno por regla de negocio— para verificar justo lo que el fake no puede: la serialización, las restricciones del esquema, el ida-y-vuelta real.
- Escribe un contrato del repositorio (módulos 3-4): una sola batería de tests que corres contra el fake y contra el real. Si el fake y el real divergen en cualquier punto del contrato, la batería lo grita —esa es la herramienta que convierte "esperemos que el fake no mienta" en "el fake no puede mentir sin que un test rojo lo delate"—.
Así tienes velocidad (unit), verdad en las juntas (integración) y una garantía sistemática de que el doble no diverge (contrato), sin sacrificar la forma de la pirámide.
Resumen y siguiente paso
En esta lección miraste de frente el problema que da razón de ser a toda la guía: un unit test verde puede esconder una integración rota, porque el doble que cierra la costura diverge del proveedor real justo ahí. Lo aislaste hasta su forma más pura —save y get, un solo campo— y viste el mecanismo sin ruido: el test contra el fake pasa (y no está mal: es cierto para el fake), el test contra SQLite falla en el datetime, y ninguna cantidad de unit tests podría haberlo evitado, porque todos comparten la premisa falsa que el fake encarna. Con el doble de riesgo y su piscina de red, entendiste que el verde no miente sobre el ensayo; miente sobre el estreno, porque ensayaste contra algo que no es el suelo real. Y te llevas el reflejo clave: sospecha de la costura, no del verde; por cada costura doblada, pregunta si tu doble se comporta como lo real ahí.
Antes de avanzar deberías poder: traducir un verde de unit test a su afirmación estrecha ("funciona con el doble") y detectar el salto ilegítimo a la amplia; explicar por qué el volumen de unit tests no cierra la brecha; y descartar los dos falsos arreglos (culpar al unit test, o hacer que el fake imite el defecto del real).
Ya sabes que la divergencia existe y por qué se esconde. Lo que falta para completar el mapa del "por qué" es afinar el vocabulario de la integración —porque no toda integración es igual— y sopesar honestamente su costo. La lección 6 clasifica los tipos de integración (solitaria contra sociable, estrecha contra amplia, incremental contra big-bang) para que sepas nombrar con exactitud qué estás probando; la 7 mide lo que cuesta y decide cuándo vale la pena.
Recursos
- Martin Fowler — Test Double — el marco que nombra los dobles y explica por qué un doble es una representación de un colaborador, no el colaborador; la base conceptual de por qué un doble puede divergir de lo que representa.
sqlite3— Adaptadores y conversores de tipos (documentación de Python) — la sección exacta que explica por qué SQLite no guarda undatetimecomo tal y cómo (si quisieras) registrar la conversión de vuelta; la raíz técnica de la divergencia de esta lección.- Documentación de pytest — Cómo entender los reportes de fallo — para leer el bloque de
FAILURESy la líneaassert '...' == datetime(...)que delata la divergencia, como en el ejemplo trabajado. test-doubles-and-test-data-guide— la guía hermana donde construiste elFakeBookingRepository; útil para recordar que un fake es una implementación que tú escribes, y por tanto una suposición que tú haces sobre lo real.