Módulo 2: El doble que miente: el problema que motiva los contratos

8. Mini-proyecto: encuentra la divergencia que el unit test no delata

Descripción

Este es el cierre práctico del módulo, y funciona como un examen de todo lo que viste. Durante seis lecciones te mostré divergencias que yo ya había encontrado y ejecutado; ahora te toca a ti hacer el trabajo completo sobre una feature nueva, con una divergencia que no te voy a señalar de antemano. El objetivo no es que adivines la respuesta —te la daré en la solución de referencia—, sino que recorras el método: tomar una pieza de código que se prueba con un fake, sospechar dónde puede estar mintiendo el verde, y demostrarlo trayendo la pieza real. Ese método —"no le creo al verde hasta que lo confronto con lo real"— es la habilidad que la guía entera se propone instalar, y este mini-proyecto es tu primer ensayo de ella sin ruedas de apoyo.

La feature es pequeña y realista: una función que responde "¿cuándo empieza la próxima reserva de esta sala?". La usa, por ejemplo, un panel que muestra "Próxima reserva: 09:00" en la puerta de cada sala. Se ve inocente, tiene su unit test en verde con el FakeBookingRepository, y esconde una divergencia de una de las cuatro familias que estudiaste. Tu entrega no es solo "encontré el bug": es el par de tests que lo exhibe (unit verde, integración rojo), el diagnóstico de por qué el verde mentía, y —el entregable que de verdad cierra el módulo— la forma del contrato que lo habría cazado, descrito pero no implementado, porque implementarlo es el módulo 3. Al terminar, habrás vivido el ciclo completo del problema, y estarás listo para que la próxima guía te dé la solución.

Conexión con el módulo: esta lección recoge las siete anteriores y las pone en tus manos. La 1 planteó el problema; la 2 explicó por qué es inevitable; la 3, 4 y 5 catalogaron las divergencias; la 6 mostró por qué el unit test es ciego a ellas; la 7 les puso precio. El mini-proyecto te hace ejecutar ese arco entero sobre un caso propio: reproducir la mentira del doble (lecciones 3-5), entender por qué el unit test no la ve (lección 6), y valorar lo que costaría en producción (lección 7). Y termina apuntando explícitamente al módulo 3, porque el último entregable —describir el contrato que lo cazaría— es, literalmente, el enunciado del problema que el contrato consumer-driven resuelve. Cierras el módulo del problema justo en la puerta del módulo de la solución.

Analogía: el simulacro que no ensayó la salida real

Un edificio hace su simulacro de incendio y todo sale perfecto: la gente baja por las escaleras, se reúne en el punto de encuentro, el cronómetro marca un tiempo excelente. Verde. Pero el simulacro se ensayó siempre con la misma puerta de salida —la principal, amplia, despejada—. El día del incendio real, esa puerta está bloqueada por el fuego y hay que usar la salida de emergencia trasera, estrecha, que nadie practicó. El simulacro no mintió por maldad; ensayó una suposición ("saldremos por la principal") que el incendio real no respetó. Todo el mundo aprobó el simulacro y nadie estaba listo para la puerta que de verdad tocó usar.

Tu misión en este mini-proyecto es ser el inspector que se niega a firmar el simulacro sin antes preguntar: "¿y si la puerta principal está bloqueada?". Es decir: mirar la feature que pasa su unit test en verde y preguntar "¿y si el repositorio real no se comporta como el fake justo aquí?". El inspector no confía en que el simulacro salió bien; va a la puerta trasera —la pieza real— y comprueba si el plan aguanta. Encontrar la divergencia es encontrar la puerta que el simulacro nunca ensayó. Y proponer el contrato es escribir en el protocolo "ensáyese también la salida de emergencia", para que el próximo simulacro no pueda salir verde ignorando la puerta difícil.

El enunciado

Te entregan esta feature, ya escrita y con su unit test en verde:

# reservo/reporting.py — feature: ¿cuando empieza la proxima reserva de una sala?
def next_booking_start(repo, room_id, now):
    """Devuelve el start de la proxima reserva futura de la sala, o None si no hay."""
    future = [b for b in repo.find_by_room(room_id) if b.start > now]  # compara con now
    if not future:
        return None
    return min(future, key=lambda b: b.start).start                    # ordena por start

La función pide las reservas de la sala, se queda con las futuras (las que empiezan después de now) y devuelve el start de la más próxima. La lógica es correcta como algoritmo. El unit test que la acompaña, con el FakeBookingRepository, está en verde:

def test_next_start_with_fake():
    repo = FakeBookingRepository()
    seed(repo)                                      # tres reservas de Focus: 11h, 9h, 10h
    assert next_booking_start(repo, "focus", NOW) == datetime(2026, 3, 10, 9)

Entregables

  1. El test de integración que expone la divergencia. Escribe un test con el mismo escenario pero usando el SqliteBookingRepository real en vez del fake. Debe ponerse rojo. Pega la salida real de pytest.
  2. El diagnóstico. En prosa: ¿qué divergencia es (comportamiento, tipo, orden, unicidad/transacción)? ¿Por qué el fake la esconde y el real la revela? ¿Qué afirmaba en realidad el unit test verde, con su condición escondida?
  3. La corrección, en el lugar correcto. Arregla la divergencia. Decide si va en la feature o en el repositorio, y justifícalo. Deja el test de integración en verde.
  4. La forma del contrato (sin implementarlo). Describe en una o dos frases el acuerdo de comportamiento que, verificado contra el fake y el real, habría cazado esto antes del deploy. No lo implementes —eso es el módulo 3—; solo enúncialo con precisión.
  5. La factura, en una frase. Estima el costo en producción si esta divergencia se hubiera desplegado: ¿qué vería el usuario, y en qué dimensión (detección tardía, radio, depuración) pega más fuerte?

Rúbrica

Se evalúa el método, no solo si encuentras el bug. Cada fila es una capacidad del módulo.

CriterioInsuficienteCompetenteExcelente
Exponer la divergenciaEl test de integración no se pone rojo, o no usa el repo real.Un test de integración rojo con el SqliteBookingRepository.El test aísla el escenario mínimo que diverge y la salida deja ver la causa (el tipo, el orden) en el traceback.
Diagnóstico"Falla y ya" o culpa a la pieza equivocada.Nombra la familia de divergencia y por qué el fake la esconde.Reformula qué afirmaba el verde con su condición escondida, como en la lección 3.
CorrecciónEn el sitio equivocado (parchea el test o cada cliente).En el lugar correcto, con el test en verde.Justifica por qué va en el repositorio (todos los clientes heredan el contrato) y no rompe otros casos.
ContratoAusente o vago.Un acuerdo de comportamiento enunciado.Un acuerdo preciso que, corrido contra ambos, pondría rojo al que no cumple.
FacturaNo la estima.Nombra qué vería el usuario.Ubica la dimensión de costo dominante y por qué.

Ejemplo trabajado: la solución de referencia

Aquí está la entrega completa, la que tú producirías. Empecemos por el test de integración (entregable 1). Es el mismo escenario del unit test, cambiando solo el repositorio:

# tests/test_mini_project.py
def seed(repo):
    repo.save(a_booking("bk-11", 11))
    repo.save(a_booking("bk-9", 9))
    repo.save(a_booking("bk-10", 10))


def test_next_start_with_fake():
    repo = FakeBookingRepository()
    seed(repo)
    assert next_booking_start(repo, "focus", NOW) == datetime(2026, 3, 10, 9)


def test_next_start_with_sqlite():
    repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
    seed(repo)
    assert next_booking_start(repo, "focus", NOW) == datetime(2026, 3, 10, 9)

Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1): el fake pasa, el real revienta.

python3 -m pytest tests/test_mini_project.py -v
tests/test_mini_project.py::test_next_start_with_fake PASSED             [ 50%]
tests/test_mini_project.py::test_next_start_with_sqlite FAILED           [100%]

=================================== FAILURES ===================================
_________________________ test_next_start_with_sqlite __________________________

    def test_next_start_with_sqlite():
        repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
        seed(repo)
>       assert next_booking_start(repo, "focus", NOW) == datetime(2026, 3, 10, 9)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

tests/test_mini_project.py:34:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

repo = <reservo.sqlite_repo.SqliteBookingRepository object at 0x10767b230>
room_id = 'focus', now = datetime.datetime(2026, 3, 10, 8, 0)

    def next_booking_start(repo, room_id, now):
        """Devuelve el start de la proxima reserva futura de la sala, o None si no hay."""
>       future = [b for b in repo.find_by_room(room_id) if b.start > now]  # compara con now
                                                           ^^^^^^^^^^^^^
E       TypeError: '>' not supported between instances of 'str' and 'datetime.datetime'

reservo/reporting.py:4: TypeError
========================= 1 failed, 1 passed in 0.04s ==========================

El diagnóstico (entregable 2). Es una divergencia de tipo, de la familia de la lección 4. La función hace b.start > now, comparando el start de cada reserva con un datetime. Con el FakeBookingRepository, b.start es un datetime —el fake guarda y devuelve el objeto intacto—, así que la comparación datetime > datetime funciona y el test pasa. Con el SqliteBookingRepository real, b.start vuelve como str —SQLite serializó el datetime a texto ISO al guardarlo y nadie lo convirtió de vuelta—, y Python no sabe comparar un str con un datetime: lanza TypeError: '>' not supported between instances of 'str' and 'datetime.datetime'. El fake esconde la divergencia porque no serializa; el real la revela porque sí. Y lo que el unit test verde afirmaba en realidad no era "la función devuelve la próxima reserva", sino "la función devuelve la próxima reserva cuando el repositorio entrega start como datetime" —una condición que el fake cumplía en silencio y que el real no cumple—.

La corrección (entregable 3). Va en el repositorio, no en la feature. La raíz es que el SqliteBookingRepository no honra el contrato implícito "get/find_by_room devuelven Bookings con start de tipo datetime". Se corrige reconstruyendo el datetime al leer:

from datetime import datetime

# en find_by_room y get del SqliteBookingRepository, al construir el Booking:
start=datetime.fromisoformat(row[3]),   # str ISO -> datetime
end=datetime.fromisoformat(row[4]),

¿Por qué en el repositorio y no en next_booking_start? Porque next_booking_start no es el único que lee start esperando un datetime: el código de pantalla (.strftime()), los cálculos de reembolso (que restan fechas), cualquier comparación temporal, todos comparten la misma expectativa. Si "arreglaras" solo next_booking_start (por ejemplo, convirtiendo ahí el str a datetime), los demás clientes seguirían rotos, y repetirías el parche por decenas de sitios hasta olvidarlo en alguno. La responsabilidad de entregar Bookings con los tipos correctos es de la pieza que implementa la interfaz —el repositorio—, para que todos sus clientes hereden el contrato de una sola corrección. Con fromisoformat en el repositorio, el test de integración pasa y todos los clientes quedan protegidos a la vez.

La forma del contrato (entregable 4). El acuerdo: "find_by_room(room_id) y get(id) devuelven Bookings cuyos campos start y end son de tipo datetime, no str." Verificado contra el fake y el real, este acuerdo pasa con el fake (que ya devuelve datetime) y falla con el SqliteBookingRepository sin corregir (que devuelve str), poniéndose rojo en la máquina del desarrollador antes de cualquier deploy. No lo implementamos aquí —construir esa batería y correrla contra ambas implementaciones es el módulo 3—; basta con ver que el enunciado del acuerdo es preciso y comprobable.

La factura (entregable 5). Si esto se despliega, el panel de cada sala revienta con un error 500 al intentar calcular la próxima reserva —una pantalla visible, en la puerta de cada sala, rota para todos—. La dimensión dominante es el radio de impacto: no es un caso borde raro, es la función principal de una pantalla que se ve constantemente, así que afecta a cada persona que mire el panel de cualquier sala con reservas futuras. Detección tardía y depuración también pegan (el verde autorizó el deploy; el TypeError estalla en reporting.py por una serialización decidida en sqlite_repo.py), pero es el radio —una pantalla omnipresente caída para todos— lo que hace este incidente especialmente caro.

Variantes para practicar el método

Si quieres afianzar el método con más repeticiones, aquí van tres variantes de la misma feature, cada una con una divergencia de una familia distinta. Para cada una, repite el ciclo: escribe el par unit-verde / integración-rojo, diagnostica y propón el contrato.

Variante A — orden. Cambia next_booking_start por list_room_bookings(repo, room_id) que devuelve las reservas "en el orden en que se crearon" y afirma [0].id == "bk-primera". Pruébala con el fake y con el SqliteBookingRepository con un índice en (room_id, start). ¿Qué familia es? ¿Dónde va la corrección?

Ver pista

Es la divergencia de orden (lección 4). El fake devuelve orden de inserción; el real con índice devuelve orden del índice. La corrección va en el repositorio: ORDER BY explícito que defina el orden que la feature necesita —y si la feature quiere "orden de creación", el esquema necesita un campo que lo represente (un created_at o un id cronológico), porque "orden de inserción" no es una propiedad que el motor prometa—. El contrato: "find_by_room devuelve las reservas ordenadas por <campo explícito>".

Variante B — comportamiento. Cambia la feature por cancel_next(repo, room_id, now) que cancela la próxima reserva, y pruébala en una sala sin reservas futuras, donde el código hace repo.get(next_id) con un next_id que no existe. Usa el fake descuidado (get→None) y el real (get→lanza).

Ver pista

Es la divergencia de comportamiento (lección 3), la del get→None vs get→lanza. El fake descuidado devuelve None y el código sigue; el real lanza KeyError. La corrección va en el código que consume (manejar la ausencia con try/except o comprobando antes si hay reservas), respetando el contrato "get de un id ausente lanza". El contrato: "get(id) de un id no guardado lanza".

Variante C — unicidad. Cambia la feature por reserve_slot(repo, room_id, start) que guarda una reserva, y pruébala guardando dos reservas distintas para la misma sala y hora. Usa el fake y el SqliteBookingRepository con UNIQUE(room_id, start).

Ver pista

Es la divergencia de unicidad (lección 5). El fake acepta las dos (no modela el constraint); el real rechaza la segunda con IntegrityError. Aquí el verde del fake certifica un bug de negocio (doble reserva) como correcto. La corrección tiene dos partes: el constraint en la base de datos (que el real ya tiene) y el manejo del IntegrityError en el código que reserva. El contrato: "guardar dos reservas distintas para la misma sala y hora debe fallar".

Errores comunes

Parchear la feature en vez de arreglar el repositorio. Qué pasa: alguien ve el TypeError en next_booking_start y "arregla" ahí mismo, convirtiendo el str a datetime dentro de la feature. Por qué pasa: es donde estalla el error, así que parece el lugar natural. Cómo detectarlo: pregúntate cuántos otros sitios leen start esperando un datetime —los hay, y todos siguen rotos con tu parche local—. Cómo corregirlo: la divergencia nace en cómo el repositorio serializa; se cura en el repositorio, para que todos los clientes hereden la corrección. Parchear el cliente mueve el bug de sitio y lo multiplica.

Diagnosticar "el real está mal" en vez de "el real revela la divergencia". Qué pasa: alguien concluye que el SqliteBookingRepository tiene un bug porque "devuelve str en vez de datetime". Por qué pasa: el real es el que falla el test, así que parece el culpable. Cómo detectarlo: el real hace lo que SQLite permite —guardar texto—; el "bug" es que el repositorio no completó el contrato al leer (no reconvirtió el texto a datetime). El real no está mal por serializar; está incompleto por no deserializar. Cómo corregirlo: enmarca el hallazgo como "el repositorio no honra el contrato de tipos", no como "el real está roto". La diferencia importa para arreglar la pieza correcta.

Entregar el test rojo sin el contrato. Qué pasa: alguien exhibe la divergencia con el test de integración y da el mini-proyecto por hecho, sin describir el contrato que la cazaría. Por qué pasa: el test rojo se siente como la entrega. Cómo detectarlo: si no puedes escribir en una frase el acuerdo de comportamiento que, corrido contra ambos, se pondría rojo, te falta el entregable que conecta este módulo con el siguiente. Cómo corregirlo: el mini-proyecto no termina en "encontré el bug"; termina en "aquí está el acuerdo que lo previene". Ese acuerdo es el puente al módulo 3, y enunciarlo es demostrar que entendiste que la solución no es cazar bugs uno por uno, sino verificar acuerdos.

Resumen y siguiente paso

En este mini-proyecto recorriste, por tu cuenta, el ciclo completo del problema que el módulo enseñó. Tomaste una feature inocente con su unit test en verde, sospechaste del verde, y trajiste la pieza real para exponer una divergencia de tipo —el start que vuelve como str del SqliteBookingRepository y hace estallar la comparación str > datetime—. La diagnosticaste (qué familia, por qué el fake la esconde, qué afirmaba de verdad el verde), la corregiste en el lugar correcto (el repositorio, para que todos los clientes hereden el contrato), le pusiste precio (una pantalla omnipresente caída, radio amplio), y —lo que cierra el arco— describiste el contrato que la habría cazado antes del deploy. Con las tres variantes, practicaste el mismo método sobre las otras tres familias.

Con esto cierras el módulo 2 y, con él, el planteamiento completo del problema de la guía. Ahora sabes: que un doble es una suposición que puede fallar en verde; por qué divergir es su tendencia natural; las cuatro familias de divergencia, ejecutadas; por qué el unit test es estructuralmente incapaz de verlas; cuánto cuesta cuando llegan a producción; y cómo reproducir, diagnosticar y valorar una divergencia tú mismo. Tienes el problema entero en la mano, y lo tienes con la incomodidad justa para querer la solución.

Esa solución empieza en el módulo 3. El último entregable de este mini-proyecto —"describe el acuerdo de comportamiento que, corrido contra el fake y el real, cazaría la divergencia"— es, palabra por palabra, la definición de un contrato. El módulo 3 toma ese enunciado y lo convierte en código: una batería de tests consumer-driven que se corre contra ambas implementaciones y se pone roja en el instante en que discrepan, de modo que ninguna de las divergencias de este módulo pueda volver a llegar verde a producción. Dejamos el problema perfectamente planteado; vamos a resolverlo.

Recursos