Módulo 1: De la unidad a la integración: por qué

8. Mini-proyecto: fake en verde, real en rojo

Descripción

Este es el cierre práctico del módulo. Las siete lecciones anteriores te dieron el "por qué" pieza por pieza —la diferencia entre unidad e integración, la pirámide, las costuras, el problema del verde que engaña, los tipos de integración y su costo—. Ahora te toca tejerlo todo en un solo entregable que demuestra la tesis central de la guía con tus propias manos: un unit test con el FakeBookingRepository pasa en verde, mientras el test de integración con el SqliteBookingRepository real falla en rojo por la divergencia de la costura. No es una herramienta nueva; es el módulo entero condensado en dos tests y un diagnóstico.

Lo que de verdad se evalúa aquí no es que consigas el rojo —eso es fácil— sino que puedas explicar por qué el verde mentía. Cualquiera puede pegar dos tests y ver que uno falla. El entregable que importa es el diagnóstico: nombrar la costura donde divergen los dos proveedores, señalar el campo exacto que cambia de forma al cruzarla, explicar por qué el fake no podía atrapar el bug (porque el fake es la suposición que resultó falsa), y ubicar dónde vive la solución en el resto de la guía. Un alumno que entrega el rojo sin el diagnóstico no demostró que entendió el módulo; uno que entrega el diagnóstico completo demostró que ya no volverá a leer una suite verde de puros unit tests sin preguntar qué costuras quedaron sin verificar.

Conexión con el módulo: esta lección es el examen práctico del módulo 1 y su cierre. Recoge la brecha que la lección 1 te mostró, la definición de la lección 2, la costura de la lección 4, el mecanismo del engaño de la lección 5 y el criterio de costo de la lección 7, y los presenta como un proyecto con entregables y solución de referencia. Después del enunciado, resume el módulo y te apunta al módulo 2, donde veremos otra divergencia de la misma costura —el fake que devuelve None donde el real lanza— para empezar a construir, a partir del módulo 3, la solución sistemática: el contrato.

Analogía: el reporte del inspector

Piensa en un inspector de edificios que encuentra una junta mal calculada —la del ejemplo de la lección 1, la viga que no apoya bien en la columna—. Su trabajo no termina cuando ve la grieta; termina cuando entrega el reporte: dónde está la junta, qué dos piezas conecta, por qué cede (el perno corto, el acero que se dilata distinto), por qué la revisión pieza-por-pieza no lo detectó (cada pieza cumplía su norma por separado), y a quién le toca arreglarlo. Un inspector que solo dice "algo se ve mal" no sirve; uno que entrega el reporte completo permite que se arregle lo correcto, en el lugar correcto, por la razón correcta.

Tu mini-proyecto es ese reporte. El rojo del test de integración es la grieta que encontraste; el entregable es el reporte que la explica. Vas a documentar dónde está la costura, qué dos proveedores diverge en ella, qué campo cede al cruzarla, por qué los unit tests verdes no la detectaron, y dónde —en qué módulos de la guía— vive el arreglo. Entregar el reporte, no solo la grieta, es lo que separa "vi que falla" de "entiendo por qué falla y qué hacer al respecto".

El proyecto: enunciado formal

Tu tarea es demostrar, de principio a fin y con salida real de pytest, que un unit test verde puede esconder una integración rota en Reservo. Concretamente:

Escribe dos tests del mismo comportamiento de negocio —reservar Focus 3 h para la socia pro Ana y verificar que la reserva quedó guardada correctamente (con price_cents == 6000 y el start que se reservó)—:

  • El unit test usa el FakeBookingRepository (y stub de pago, spy de correo, reloj fijo). Debe pasar en verde.
  • El test de integración usa el SqliteBookingRepository real (mismo pago, correo y reloj doblados; solo cambia el repositorio). Debe fallar en rojo, en la aserción del start.

Entregables

  1. Los dos tests, en un solo archivo, idénticos salvo por el repositorio que reciben. La única diferencia entre ellos debe ser la pieza enchufada en la costura repo; todo lo demás —los datos, las aserciones— igual.
  2. La salida real de pytest de correr el archivo con -v, mostrando el unit test en PASSED y el de integración en FAILED, con el bloque de AssertionError que delata el tipo del start.
  3. El diagnóstico (el entregable que más pesa), respondiendo cuatro preguntas: (a) ¿en qué costura divergen los dos proveedores? (b) ¿qué campo cambia de forma al cruzarla, y a qué tipo? (c) ¿por qué el unit test con el fake no podía atrapar este bug? (d) ¿dónde, en el resto de la guía, vive la solución?
  4. La aserción que sí sobrevive. Señala la aserción que pasa en ambos tests (price_cents == 6000) y explica por qué ese campo cruza la costura sin problema mientras el start no.

Ejemplo trabajado: los dos tests y su salida

Aquí está el archivo completo: dos tests gemelos, uno con el fake, uno con el real. Léelos y fíjate en que la única diferencia real está en la línea del repositorio —FakeBookingRepository() contra SqliteBookingRepository(sqlite3.connect(":memory:"))—; el resto, incluidas las dos aserciones finales, es idéntico.

# tests/test_book_persists_correctly.py — el mismo book, dos repos distintos
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)


def make_service(repo):
    return BookingService(
        Calendar(), FixedClock(CLOCK),
        StubPaymentGateway(ok=True), SpyEmailSender(), repo,
    )


# --- unit: el repositorio es un doble en memoria ---
def test_book_persists_the_booking_with_fake_repo():
    repo = FakeBookingRepository()
    service = make_service(repo)

    booking = service.book(FOCUS, ANA, START, END)

    saved = repo.get(booking.id)
    assert saved.price_cents == 6000     # el cobro correcto
    assert saved.start == START          # la reserva se guardo intacta


# --- integracion: el repositorio es SQLite de verdad ---
def test_book_persists_the_booking_with_sqlite_repo():
    repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
    service = make_service(repo)

    booking = service.book(FOCUS, ANA, START, END)

    saved = repo.get(booking.id)
    assert saved.price_cents == 6000     # el cobro correcto
    assert saved.start == START          # <-- aqui divergen los dos repos

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

python3 -m pytest tests/test_book_persists_correctly.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_persists_correctly.py::test_book_persists_the_booking_with_fake_repo PASSED [ 50%]
tests/test_book_persists_correctly.py::test_book_persists_the_booking_with_sqlite_repo FAILED [100%]

=================================== FAILURES ===================================
_______________ test_book_persists_the_booking_with_sqlite_repo ________________

    def test_book_persists_the_booking_with_sqlite_repo():
        repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
        service = make_service(repo)

        booking = service.book(FOCUS, ANA, START, END)

        saved = repo.get(booking.id)
        assert saved.price_cents == 6000     # el cobro correcto
>       assert saved.start == START          # <-- aqui divergen los dos repos
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_book_persists_correctly.py:50: AssertionError
========================= 1 failed, 1 passed in 0.02s ==========================

Ahí está la demostración completa: 1 failed, 1 passed. El mismo book, la misma aserción, dos veredictos —verde con el fake, rojo con el real—, y el rojo señalando la línea exacta y el tipo exacto: '2026-03-10T09:00:00' (un str) contra datetime.datetime(2026, 3, 10, 9, 0). Con esto tienes los entregables 1 y 2. Falta el que pesa: el diagnóstico.

Solución de referencia

Ver el diagnóstico completo (los entregables 3 y 4)

Entregable 3 — el diagnóstico, cuatro preguntas.

(a) ¿En qué costura divergen los dos proveedores? En la costura del repositorio: el punto donde BookingService (consumidor) se conecta con un BookingRepository (proveedor) a través de la interfaz save/get/find_by_room. El FakeBookingRepository y el SqliteBookingRepository son dos proveedores de esa misma costura, y encajan igual (ambos tienen los métodos), pero se comportan distinto en el ida-y-vuelta de save seguido de get. La divergencia no está en la lógica de BookingService —que es idéntica en ambos tests— sino en el proveedor enchufado.

(b) ¿Qué campo cambia de forma, y a qué tipo? El campo start, de tipo datetime. Al cruzar la costura hacia SQLite se serializa a texto (en save, con .isoformat(), porque una columna de base de datos no guarda objetos datetime de Python) y vuelve como str en get, porque nadie lo convierte de regreso a datetime. Así, saved.start es '2026-03-10T09:00:00' (un str) en vez de datetime(2026, 3, 10, 9, 0), y saved.start == START es str == datetime, que es False. El fake, en cambio, guarda el objeto entero en un dict sin serializar nada, así que devuelve el datetime intacto.

(c) ¿Por qué el unit test con el fake no podía atrapar el bug? Porque el fake es la suposición que resultó falsa. Cuando escribimos FakeBookingRepository.get para que devolviera el objeto guardado tal cual, codificamos la premisa "leer una reserva la devuelve idéntica a como se guardó". El unit test, al probar contra el fake, no verifica esa premisa: la da por buena —no puede hacer otra cosa, porque el fake la encarna—. Un test no puede atrapar un error que vive en su propia premisa. Por eso el verde es verdadero (el fake de verdad devuelve el datetime intacto) pero engañoso: garantiza "funciona con el doble", no "funciona con la pieza real". El salto de lo primero a lo segundo es el engaño, y solo un test que cruce la costura con el proveedor real —la integración— puede desmentirlo.

(d) ¿Dónde vive la solución en el resto de la guía? En dos lugares complementarios. El arreglo del bug en sí vive en el proveedor: SqliteBookingRepository.get debería convertir el texto de vuelta a datetime (con datetime.fromisoformat(row[3])), o registrar un conversor de tipos en sqlite3, para que su get cumpla el mismo contrato que el fake. La garantía sistemática de que el fake y el real no divergen es el contract testing de los módulos 3 y 4: una sola batería de comportamiento —"guardar y leer devuelve la misma reserva, con los mismos tipos"— que se corre contra ambos proveedores, de modo que cualquier divergencia futura salga en rojo de inmediato en vez de esconderse hasta producción. Y la integración a fondo con el repositorio real, las transacciones, los archivos y HTTP es de los módulos 5 a 7. En el módulo 1 no arreglamos nada: solo demostramos, entendemos y valoramos la brecha.

Entregable 4 — la aserción que sí sobrevive. La aserción saved.price_cents == 6000 pasa en ambos tests, incluido el de integración. La razón: price_cents es un entero, y SQLite tiene un tipo nativo para enteros —la columna price_cents INTEGER guarda 6000 como número y lo devuelve como número—. El valor cruza la costura sin cambiar de tipo, así que saved.price_cents == 6000 es int == int en los dos casos. El datetime no corre la misma suerte porque SQLite no tiene un tipo nativo para él: hay que serializarlo a texto, y en esa conversión pierde su tipo. La moraleja del contraste: la divergencia de una costura no es "todo o nada"; puede afectar a unos campos (los que la costura tiene que serializar) y no a otros (los que tienen un tipo nativo del otro lado). Por eso el bug es tan traicionero —el objeto se ve casi bien, solo un campo está mal— y por eso un contrato debe verificar cada campo, no solo que "algo se guardó".

Errores comunes

Entregar el rojo sin el diagnóstico. Qué pasa: alguien pega los dos tests, muestra el 1 failed, 1 passed y da el proyecto por hecho. Por qué pasa: el rojo "se siente" como la entrega, porque es lo visible. Cómo detectarlo: si no puedes responder las cuatro preguntas del diagnóstico —costura, campo, por qué el fake no lo vio, dónde vive el arreglo—, te falta el entregable central. Cómo corregirlo: el mini-proyecto evalúa la comprensión, no el rojo; el rojo es la evidencia, el diagnóstico es la tesis. Escribe el reporte del inspector, no solo la foto de la grieta.

Hacer que los dos tests difieran en más que el repositorio. Qué pasa: alguien, sin querer, usa datos distintos o aserciones distintas en el unit y en el de integración, y el contraste deja de ser limpio. Por qué pasa: se escriben por separado y se cuela una diferencia. Cómo detectarlo: si al comparar los dos tests hay más de una línea distinta (la del repositorio), el experimento no está controlado —no puedes atribuir el rojo solo a la costura—. Cómo corregirlo: extrae todo lo común a un helper (make_service(repo)) y a constantes compartidas (START, END), de modo que los dos tests sean idénticos salvo por la pieza enchufada. Un experimento con una sola variable es el que prueba algo.

Concluir "los fakes son malos, hay que borrarlos". Qué pasa: alguien, impresionado por el rojo, decide que el problema son los fakes y que hay que probar todo contra SQLite. Por qué pasa: si el fake escondió el bug, el fake parece el culpable. Cómo detectarlo: si tu conclusión es "fuera los dobles", contradice la pirámide (lección 3) y el costo (lección 7). Cómo corregirlo: el fake no es el culpable; la conclusión ilegítima que sacamos de su verde lo es. El fake sigue siendo tu velocidad y tu precisión en la base de la pirámide; lo que faltaba era el test de integración que cubre la costura y —a partir del módulo 3— el contrato que mantiene honesto al fake. La respuesta no es menos dobles: es dobles más integración más contrato, cada uno en su lugar.

Ejercicios

Ejercicio 1 — Predice otra divergencia de la misma costura. Sin correr nada, diseña un tercer test que exponga una divergencia distinta del datetime entre el fake y el real, usando el end de la reserva. Predice qué hará el fake y qué hará SQLite, y en qué aserción fallaría el de integración.

Ver solución

El end es también un datetime, así que sufre exactamente la misma serialización que el start. Un test que reserve y luego verifique saved.end == END:

def test_book_persists_end_with_sqlite_repo():
    repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
    service = make_service(repo)
    booking = service.book(FOCUS, ANA, START, END)
    saved = repo.get(booking.id)
    assert saved.end == END              # END = datetime(2026, 3, 10, 12)

El fake devolvería saved.end como el datetime(2026, 3, 10, 12) intacto, así que la aserción pasaría. SQLite devolvería saved.end como el str '2026-03-10T12:00:00', y la aserción assert saved.end == START... perdón, assert saved.end == END fallaría con AssertionError: assert '2026-03-10T12:00:00' == datetime.datetime(2026, 3, 10, 12, 0).

Lo que esto muestra: la divergencia del datetime no es un accidente de un campo; afecta a todos los campos datetime que cruzan la costura, porque la causa es la costura (la serialización a texto), no el campo. Por eso un contrato debe cubrir cada campo del round-trip: start, end, y cualquier otro tipo sin equivalente nativo en la base de datos. Un contrato que solo verificara el price_cents daría una falsa tranquilidad idéntica a la del unit test.

Ejercicio 2 — El arreglo, y por qué no lo hacemos aún. El diagnóstico dice que el arreglo del datetime vive en SqliteBookingRepository.get, convirtiendo el texto de vuelta con datetime.fromisoformat(...). Escribe cómo quedaría la línea, y explica por qué en el módulo 1 no lo aplicamos y dejamos el rojo.

Ver solución

La línea arreglada en SqliteBookingRepository.get, en vez de start=row[3], end=row[4], sería:

from datetime import datetime
# ...
    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 eso, get devolvería start como un datetime de verdad, cumpliría el mismo contrato que el fake, y el test de integración pasaría a verde.

Por qué no lo aplicamos en el módulo 1: porque el objetivo de este módulo es el por qué, no el cómo. Aplicar el arreglo aquí resolvería esta divergencia puntual, pero saltaría por encima de la lección que el módulo enseña: que la brecha existe, que un doble puede mentir, que necesitas una herramienta sistemática —no un parche por bug— para garantizar que el fake y el real no divergen. Esa herramienta es el contrato de los módulos 3 y 4. Si arregláramos el datetime a mano ahora, mañana aparecería la divergencia del None-contra-lanza (módulo 2), y la del NOT NULL, y las estaríamos parchando de a una sin nunca construir la garantía que las previene todas. El rojo se queda en rojo a propósito: es el problema que la guía entera va a resolver bien, no con una curita.

Ejercicio 3 — Explícaselo a tu equipo. Un compañero, viendo tu mini-proyecto, dice: "pero nuestros 400 unit tests están todos en verde, esto es un caso rebuscado". Escribe la respuesta de tres frases que le darías, apoyándote en lo que demostraste.

Ver solución

Una respuesta posible, en tres frases:

"Nuestros 400 unit tests en verde solo garantizan una cosa: que la lógica funciona con los dobles que les pusimos —no que funcione con SQLite, que es lo que corre en producción—. Este mini-proyecto no es rebuscado: es literalmente nuestro book, con la única diferencia de enchufar el repositorio real en vez del fake, y basta ese cambio para que una reserva guardada devuelva el start como texto en vez de fecha, algo que ninguno de los 400 tests puede ver porque todos usan el fake que devuelve la fecha intacta. La lección no es que los unit tests sobren, sino que nos falta cubrir las costuras de frontera con integración y con un contrato —los módulos que siguen—, porque una suite de puros dobles, por verde que esté, no dice nada sobre las juntas con las piezas reales."

Lo esencial de la respuesta: no atacar los unit tests (son necesarios) ni dramatizar (no es un caso raro, es la costura más común de cualquier sistema con base de datos), sino reencuadrar el verde —"funciona con el doble", no "funciona"— y nombrar lo que falta: integración en las costuras de riesgo y un contrato que mantenga honestos a los dobles. Es la tesis del módulo, dicha para convencer.

Resumen y cierre del módulo

Con este mini-proyecto entregado, cierras el módulo 1. Demostraste con tus manos la tesis que sostiene la guía: el mismo book de Reservo pasa en verde con el FakeBookingRepository y falla en rojo con el SqliteBookingRepository real, y supiste explicar por qué —la costura del repositorio, el datetime que se serializa a texto y vuelve como str, el fake que no podía atrapar el bug porque encarnaba la premisa falsa, y el price_cents entero que sí sobrevive porque tiene tipo nativo en la base—. Entregaste el reporte del inspector, no solo la foto de la grieta.

Recorriste el módulo entero: la brecha entre doblar y probar lo real (lección 1); las definiciones precisas de unidad e integración (lección 2); la pirámide que dicta la proporción (lección 3); las costuras donde vive la integración (lección 4); el mecanismo del engaño del verde que esconde un rojo (lección 5); los tipos de integración para nombrar qué pruebas (lección 6); y el costo que le da forma a todo (lección 7). Sales sabiendo por qué existe la integración, con el reflejo de mirar cualquier suite verde y preguntar qué costuras quedaron sin verificar contra el mundo.

Hacia dónde sigue la guía. El módulo 1 fue el diagnóstico; ahora empieza el tratamiento. El módulo 2 toma otra divergencia de esta misma costura —el fake que devuelve None donde el SqliteBookingRepository real lanza— y muestra cómo ese bug pasa el unit test y explota en producción, afinando el problema hasta dejarlo listo para su solución. A partir del módulo 3, esa solución llega: el contrato consumer-driven, una batería de comportamiento que corres contra el fake y contra el real, de modo que ninguna divergencia pueda volver a esconderse detrás de un verde. De ahí en adelante (módulos 5 a 7) llevarás la integración a los recursos reales —SQLite, archivos, HTTP— con su costo bien administrado. La brecha que hoy solo viste, la vas a cerrar.

Recursos