Módulo 4: Verificar el contrato desde ambos lados

8. Mini-proyecto: verifica el contrato desde ambos lados

Descripción

Este es el cierre práctico del módulo. Las siete lecciones anteriores te dieron el contrato visto desde sus dos sillas —el lado del consumer, el lado del provider—, la garantía de correr una sola batería contra ambos, las dos formas de romperlo (el breaking change del provider, la suposición de más del consumer) y la pregunta de gobierno (quién lo posee). Ahora te toca tejerlo en un solo entregable que demuestra el pago central de la guía con tus propias manos: verificar el contrato del BookingRepository desde ambos lados —verde contra el Fake y contra el Sqlite real— y luego introducir tú mismo un cambio incompatible en el SqliteBookingRepository para verlo cazado en rojo, antes de cualquier deploy.

Lo que de verdad se evalúa aquí no es que consigas el verde ni el rojo —los dos son fáciles de producir— sino que puedas explicar por qué el contrato es la red que cazó el cambio. Cualquiera puede pegar una batería y romper una línea. El entregable que importa es el diagnóstico: nombrar qué cláusula se rompió, en qué lado ([sqlite], no [fake]), por qué esa asimetría señala al provider real como culpable, qué habría pasado en producción sin la batería, y cuál es la salida correcta (revertir o renegociar el contrato, nunca aflojar el test). Un alumno que entrega el rojo sin el diagnóstico demostró que sabe romper código; uno que entrega el diagnóstico completo demostró que entendió por qué un contrato consumer-driven, corrido antes del deploy, convierte un incidente de producción en un test rojo local de dos centésimas de segundo.

Conexión con el módulo: esta lección es el examen práctico del módulo 4 y su cierre. Recoge la batería de la lección 4 (verde contra ambos), la técnica de la lección 5 (cazar un breaking change del provider) y el marco de la lección 7 (el consumer posee el contrato), y los presenta como un proyecto con entregables y solución de referencia. Después del enunciado, resume el módulo y apunta al módulo 5, donde dejaremos el contrato del repositorio aislado para probar los componentes reales juntos —la integración de verdad—.

Analogía: el simulacro de incendio

Piensa en un edificio de oficinas que instala un sistema de rociadores contra incendios. Instalarlo no basta: hay que hacer un simulacro para probar que de verdad funciona. El simulacro tiene dos partes. Primero, se verifica que todo está en orden con el sistema en reposo: los sensores en verde, la presión del agua correcta, las salidas despejadas. Segundo —y esto es lo que convierte una instalación en una garantía— se provoca una señal de humo controlada, a propósito, para comprobar que los rociadores se activan. Nadie confía en un sistema contra incendios que nunca se probó con humo real; el simulacro es lo que demuestra que la alarma suena cuando debe.

Tu mini-proyecto es ese simulacro. La primera parte es la batería en verde contra ambos providers: el sistema en reposo, todo en orden. La segunda parte —la que demuestra que el contrato es una alarma viva y no un adorno— es provocar tú mismo el "humo": introducir un breaking change en el SqliteBookingRepository y comprobar que la batería se pone roja, en la cláusula exacta, señalando al provider real. Un contrato que solo has visto verde es como un rociador que nunca probaste con humo: no sabes si saltaría. El simulacro —el breaking change deliberado— es lo que te da la certeza de que, el día que un compañero rompa una promesa sin querer, el contrato lo cazará antes del deploy. Entregar el simulacro completo, no solo el sistema en reposo, es lo que separa "instalé un contrato" de "sé que mi contrato protege".

El proyecto: enunciado formal

Tu tarea es demostrar, de principio a fin y con salida real de pytest, que el contrato del BookingRepository verifica ambos lados y caza un breaking change del provider antes del deploy. Concretamente:

Parte 1 — Verifica el contrato desde ambos lados. Escribe (o reutiliza) la batería de contrato del BookingRepository con sus cuatro cláusulas —guardar-y-leer devuelve la misma reserva; get de un id ausente lanza; guardar dos veces el mismo id actualiza sin duplicar; find_by_room devuelve solo las reservas de esa sala— parametrizada con una fixture params=["fake", "sqlite"]. Córrela y muestra los ocho verdes: las cuatro cláusulas honradas por el FakeBookingRepository y por el SqliteBookingRepository real.

Parte 2 — Introduce un breaking change y cázalo. Modifica tú mismo el SqliteBookingRepository para romper una de las promesas del contrato. Vuelve a correr la misma batería —sin tocar los tests— y muestra el rojo: la cláusula exacta en el lado [sqlite], con el [fake] intacto.

Entregables

  1. La batería de contrato, parametrizada contra el fake y el real, con sus cuatro cláusulas.
  2. La salida real de la parte 1: los ocho verdes ([fake] y [sqlite]), prueba de que ambos lados honran el contrato.
  3. El breaking change: el diff exacto de lo que cambiaste en el SqliteBookingRepository, y cuál cláusula esperas que rompa.
  4. La salida real de la parte 2: el rojo que lo caza, mostrando la cláusula, el lado [sqlite], y el [fake] que sigue verde.
  5. El diagnóstico (el entregable que más pesa), respondiendo: (a) ¿qué cláusula se rompió y por qué solo el lado [sqlite]? (b) ¿qué habría pasado en producción sin la batería? (c) ¿cuál es la salida correcta ante el rojo, y por qué "aflojar el test" no lo es?

Ejemplo trabajado, parte 1: el contrato verde por ambos lados

Aquí está la batería completa —la de la lección 4— y su corrida en verde. Los ocho verdes son la prueba de que el fake y el real honran el mismo contrato.

# tests/test_repository_contract.py — la bateria de contrato, cuatro clausulas, dos providers
import sqlite3
from datetime import datetime

import pytest

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(id="bk-1", room_id="focus", status="confirmed", price_cents=6000):
    return Booking(id=id, room_id=room_id, member_id="m-ana",
                   start=START, end=END, status=status, price_cents=price_cents)


@pytest.fixture(params=["fake", "sqlite"])
def repo(request):
    if request.param == "fake":
        return FakeBookingRepository()
    return SqliteBookingRepository(sqlite3.connect(":memory:"))


def test_save_then_get_returns_the_same_booking(repo):
    repo.save(a_booking())
    got = repo.get("bk-1")
    assert got.id == "bk-1"
    assert got.room_id == "focus"
    assert got.start == START            # datetime, no str
    assert got.price_cents == 6000
    assert got.status == "confirmed"


def test_get_of_a_missing_id_raises(repo):
    with pytest.raises(KeyError):
        repo.get("does-not-exist")


def test_saving_the_same_id_twice_updates_not_duplicates(repo):
    repo.save(a_booking(status="confirmed"))
    repo.save(a_booking(status="cancelled"))
    got = repo.get("bk-1")
    assert got.status == "cancelled"
    assert len(repo.find_by_room("focus")) == 1


def test_find_by_room_returns_only_that_rooms_bookings(repo):
    repo.save(a_booking(id="bk-1", room_id="focus"))
    repo.save(a_booking(id="bk-2", room_id="studio"))
    ids = {b.id for b in repo.find_by_room("focus")}
    assert ids == {"bk-1"}

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

python3 -m pytest tests/test_repository_contract.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 8 items

tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[fake] PASSED [ 12%]
tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[sqlite] PASSED [ 25%]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[fake] PASSED [ 37%]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[sqlite] PASSED [ 50%]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[fake] PASSED [ 62%]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[sqlite] PASSED [ 75%]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[fake] PASSED [ 87%]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[sqlite] PASSED [100%]

============================== 8 passed in 0.01s ===============================

Con esto tienes los entregables 1 y 2: la batería y los ocho verdes. Ambos lados honran las cuatro cláusulas. El sistema en reposo, en orden. Ahora el simulacro.

Ejemplo trabajado, parte 2: el breaking change y su rojo

Introducimos el humo a propósito. El breaking change que elijo es un clásico —el bug del módulo 1, reintroducido—: hacer que SqliteBookingRepository.get deje de convertir el datetime de vuelta, devolviéndolo como el texto crudo que guarda la base de datos. El diff, en el método get (dentro de _row_to_booking):

# ANTES (honra el contrato): reconstruye el datetime desde el texto ISO
    return Booking(
        id=row[0], room_id=row[1], member_id=row[2],
        start=datetime.fromisoformat(row[3]),   # texto ISO -> datetime de vuelta
        end=datetime.fromisoformat(row[4]),
        status=row[5], price_cents=row[6],
    )

# DESPUES (BREAKING CHANGE): devuelve el texto crudo, sin reconstruir
    return Booking(
        id=row[0], room_id=row[1], member_id=row[2],
        start=row[3],   # <-- se olvida convertir a datetime; vuelve como str
        end=row[4],
        status=row[5], price_cents=row[6],
    )

Una línea (bueno, dos: start y end). Es exactamente el tipo de "simplificación" que alguien haría sin pensar —"total, es el mismo dato"—. Corremos la misma batería, sin tocar un solo test:

python3 -m pytest tests/test_repository_contract.py -v
tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[fake] PASSED [ 12%]
tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[sqlite] FAILED [ 25%]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[fake] PASSED [ 37%]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[sqlite] PASSED [ 50%]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[fake] PASSED [ 62%]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[sqlite] PASSED [ 75%]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[fake] PASSED [ 87%]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[sqlite] PASSED [100%]

=================================== FAILURES ===================================
_____________ test_save_then_get_returns_the_same_booking[sqlite] ______________

repo = <reservo.sqlite_repo.SqliteBookingRepository object at 0x101e69160>

    def test_save_then_get_returns_the_same_booking(repo):
        repo.save(a_booking())
        got = repo.get("bk-1")
        assert got.id == "bk-1"
        assert got.room_id == "focus"
>       assert got.start == START            # datetime, no str
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_repository_contract.py:34: AssertionError
=========================== short test summary info ============================
FAILED tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[sqlite]
========================= 1 failed, 7 passed in 0.02s ==========================

1 failed, 7 passed. El simulacro funcionó: la alarma sonó. La cláusula test_save_then_get_returns_the_same_booking se puso roja solo en el lado [sqlite]'2026-03-10T09:00:00' (un str) no es igual a datetime.datetime(2026, 3, 10, 9, 0)—, mientras el [fake] de la misma cláusula sigue verde y las otras tres cláusulas también. Con esto tienes los entregables 3 y 4: el diff del breaking change y el rojo que lo caza, en la cláusula exacta, antes de cualquier deploy. Falta el que pesa: el diagnóstico.

Solución de referencia

Ver el diagnóstico completo (el entregable 5)

(a) ¿Qué cláusula se rompió y por qué solo el lado [sqlite]? Se rompió test_save_then_get_returns_the_same_booking —la cláusula 1: "guardar-y-leer devuelve la misma reserva"—, en la aserción assert got.start == START. Se rompió solo en [sqlite] porque el cambio se hizo en el SqliteBookingRepository: al devolver start=row[3] sin datetime.fromisoformat(...), el provider real devuelve el start como el str '2026-03-10T09:00:00', que no es igual al datetime original. El FakeBookingRepository no cambió —sigue guardando el objeto entero en un dict y devolviéndolo con su datetime intacto—, así que su lado de la cláusula pasa. Esa asimetría ([fake] verde, [sqlite] rojo, misma cláusula) es el diagnóstico: apunta sin ambigüedad a la implementación real como la que se desvió del contrato, no al contrato ni al fake. Fíjate además en las aserciones que sobreviven: got.id, got.room_id, got.price_cents, got.status pasan aun en [sqlite], porque esos campos (texto y enteros) tienen tipo nativo en SQLite y cruzan la costura sin cambiar; solo el datetime, que hay que serializar, se rompe.

(b) ¿Qué habría pasado en producción sin la batería? El cambio habría llegado a producción sin ser visto, porque compila y todos los unit tests de BookingService (que usan el fake, el cual no cambió) siguen verdes. El bug viviría escondido hasta que algún código consumiera el start esperando un datetime —una pantalla que hace booking.start.strftime("%H:%M"), un cálculo que resta fechas— y reventara con un AttributeError: 'str' object has no attribute 'strftime' o un TypeError al operar un str como si fuera fecha. El síntoma aparecería lejos del get del repositorio y tarde (en la pantalla, en el reporte), y quien lo depurara perdería tiempo buscando en el lugar equivocado. La batería convierte ese incidente lejano en un rojo local que nombra la cláusula, el provider y el campo exacto, en el momento de correr los tests.

(c) ¿Cuál es la salida correcta ante el rojo, y por qué "aflojar el test" no lo es? La salida correcta es arreglar el provider: devolver la conversión datetime.fromisoformat(row[3]) para que el get de SQLite vuelva a cumplir el contrato. El contrato es el acuerdo, definido por lo que el consumer necesita (un start que sea un datetime, para poder formatearlo y operarlo); el rojo dice que el provider lo violó, así que se arregla el provider. "Aflojar el test" —cambiar la aserción a assert isinstance(got.start, str) o borrarla para que pase— sería invertir la relación: dejar que la implementación defectuosa redefina el acuerdo, y apagar la única alarma que detectaba el bug. El datetime-como-str volvería a producción, ahora con la bendición de una suite verde. La única forma legítima de cambiar la aserción sería si el equipo decidiera deliberadamente que start ahora es texto —y entonces habría que renegociar el contrato con todos los consumers y adaptar a cada uno que lo trataba como datetime, a la vez—. Aflojar el test en silencio para tapar el rojo nunca es la salida: reintroduce el bug y silencia la red.

Errores comunes

Entregar el rojo sin el diagnóstico. Qué pasa: alguien pega la batería, rompe una línea, muestra el 1 failed, 7 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 tres preguntas del diagnóstico —qué cláusula y por qué solo [sqlite], qué pasaría en producción, cuál es la salida correcta—, 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. Entrega el reporte del simulacro, no solo la foto de la alarma sonando.

Romper el fake en vez del provider real. Qué pasa: alguien, para conseguir el rojo, modifica el FakeBookingRepository en vez del SqliteBookingRepository. Por qué pasa: se confunde "romper una promesa" con "romper cualquier cosa". Cómo detectarlo: si tu rojo aparece en el lado [fake] y el [sqlite] sigue verde, rompiste el doble, no el provider real —que es lo contrario del escenario del módulo—. Cómo corregirlo: el breaking change del provider vive en la implementación real, la que se despliega. Rompe el SqliteBookingRepository, para que el rojo caiga en [sqlite] con el [fake] intacto: esa es la asimetría que demuestra que el contrato caza al provider real desviándose antes del deploy.

Cambiar la batería entre la parte 1 y la parte 2. Qué pasa: alguien, sin querer, edita alguna aserción al introducir el breaking change, y ya no puede afirmar que "la misma batería" cazó el cambio. Por qué pasa: se toca todo a la vez. Cómo detectarlo: si el archivo de tests difiere entre la corrida verde y la roja, el experimento no está controlado —el rojo podría deberse a tu edición del test, no al cambio del provider—. Cómo corregirlo: el poder de la demostración está en que la batería es idéntica en ambas partes; lo único que cambia es la implementación del provider. Corre la misma batería, sin tocarla, antes y después del cambio. Un experimento con una sola variable —el provider— es el que prueba algo.

Ejercicios

Ejercicio 1 — Elige otro breaking change. En vez del datetime-como-str, introduce un breaking change distinto en el SqliteBookingRepository que rompa la cláusula 3 (guardar dos veces el mismo id actualiza sin duplicar). Describe el cambio, predice el rojo, y di por qué el [fake] seguiría verde.

Ver solución

El cambio: en save, reemplazar el INSERT ... ON CONFLICT(id) DO UPDATE SET ... por un INSERT a secas (sin la cláusula de upsert). Con eso, el segundo save del mismo id ya no actualiza: intenta insertar una segunda fila con el mismo id, que es PRIMARY KEY, y SQLite lo rechaza.

El rojo predicho: test_saving_the_same_id_twice_updates_not_duplicates[sqlite] se pondría rojo. El segundo repo.save(a_booking(status="cancelled")) lanzaría sqlite3.IntegrityError: UNIQUE constraint failed: bookings.id en la línea del save, antes siquiera de llegar a las aserciones. (Es un rojo por excepción, no por aserción fallida: el test explota al guardar, no al comparar.) La promesa "guardar dos veces el mismo id actualiza, no duplica" se rompió porque el mecanismo que la cumplía —el upsert— desapareció.

Por qué el [fake] seguiría verde: el FakeBookingRepository.save hace self._store[booking.id] = booking —una asignación a un dict por clave—, que por naturaleza sobrescribe el valor anterior en vez de duplicar o fallar. El segundo save simplemente reemplaza la entrada, el get devuelve la versión cancelled, y find_by_room sigue teniendo una sola reserva. El fake no cambió y su forma de "guardar por clave" cumple la cláusula sin esfuerzo. De nuevo: [fake] verde + [sqlite] rojo = el provider real rompió una promesa que el fake sigue cumpliendo.

Ejercicio 2 — El breaking change del lado del consumer. El mini-proyecto rompió el contrato desde el provider. Diseña la versión espejo: una suposición de más del consumer que la batería del repositorio (tal como está) no cazaría, y explica qué tipo de test sí la cazaría.

Ver solución

La suposición de más: que un consumer —digamos, una función next_booking(repo, room_id)— haga repo.find_by_room(room_id)[0] suponiendo que la lista viene ordenada por start. El contrato no promete orden en find_by_room; solo promete el conjunto de reservas de esa sala.

Por qué la batería del repositorio no la cazaría: la batería verifica al provider —que find_by_room devuelva las reservas correctas de esa sala— y su cláusula 4 compara como conjunto (ids == {"bk-1"}), sin afirmar nada del orden. Ni el fake ni SQLite fallan esa cláusula por no ordenar, porque la cláusula no pide orden. La suposición de más no vive en el provider (que cumple el contrato) sino en el consumer (que se apoya en algo no prometido), y la batería del repositorio no ejercita a ningún consumer.

Qué test sí la cazaría: un test del consumer con la técnica de la lección 6 —el provider adversario-pero-legal—: correr next_booking contra un repositorio alimentado fuera de orden de start (guardar la de las 11 antes que la de las 9), de modo que find_by_room devuelva [11am, 9am] y [0] dé la reserva equivocada. Ese test del consumer falla y delata la suposición de orden. La moraleja del ejercicio: el lado del provider y el lado del consumer cazan errores distintos, y necesitas los dos —la batería del provider no ve las suposiciones de más del consumer, igual que el consumer test no ve los breaking changes del provider real—.

Ejercicio 3 — Explícaselo a tu líder técnico. Tu líder pregunta: "ya tenemos 300 unit tests verdes de BookingService. ¿Para qué añadir esta batería de contrato de 8 tests?". Escribe la respuesta de tres o cuatro frases que le darías, apoyándote en lo que demostraste en el mini-proyecto.

Ver solución

Una respuesta posible:

"Nuestros 300 unit tests verdes prueban que BookingService orquesta bien contra el FakeBookingRepository —el doble que usamos en la costura del repositorio—, pero no dicen nada sobre si el SqliteBookingRepository real, el que corre en producción, se comporta como el fake. Esta batería de 8 corre las mismas cuatro promesas contra el fake y contra el SQLite real, así que garantiza que el fake no está mintiendo: si el real diverge en cualquier promesa, un test se pone rojo. Acabo de demostrarlo: un cambio de una línea en el get de SQLite —devolver el datetime como texto, que compila y deja los 300 unit tests en verde— pone la batería roja al instante en el lado [sqlite], señalando la cláusula exacta, antes de desplegar. Sin la batería, ese bug llega a producción y revienta lejos y tarde, en la pantalla que formatea la fecha; con la batería, es un rojo local de dos centésimas de segundo que dice exactamente qué se rompió y dónde."

Lo esencial de la respuesta: no oponer el contrato a los unit tests (los 300 siguen siendo necesarios: son la base rápida de la pirámide), sino explicar qué cubre el contrato que ellos no pueden cubrir —la fidelidad del provider real frente al fake— y demostrarlo con el caso concreto del mini-proyecto: un breaking change que los 300 dejan pasar y la batería de 8 caza antes del deploy. Ocho tests que mantienen honesto al doble del que cuelgan los otros 300 son la mejor relación costo-beneficio de la suite.

Resumen y cierre del módulo

Con este mini-proyecto entregado, cierras el módulo 4. Demostraste con tus manos el pago central de la guía: verificaste el contrato del BookingRepository desde ambos lados —ocho verdes, el fake y el SQLite real honrando las cuatro cláusulas— y luego, como en un simulacro de incendio, provocaste el humo a propósito: introdujiste un breaking change en el SqliteBookingRepository (el datetime que vuelve como str) y viste la batería ponerse roja en la cláusula exacta, en el lado [sqlite], con el [fake] intacto, antes de cualquier deploy. Y supiste explicarlo: por qué la asimetría señala al provider real, qué habría pasado en producción sin la batería, y por qué la salida es arreglar el provider —nunca aflojar el test—.

Recorriste el módulo entero: el contrato tiene dos lados (lección 1); el lado del consumer, "yo mando X y espero Y", contra un provider que honra el contrato (lección 2); el lado del provider, "dado X, devuelvo Y", rindiendo cuentas solo (lección 3); la misma batería contra ambos, la garantía por transitividad de que el fake no miente (lección 4); cazar un breaking change del provider antes del deploy (lección 5); cazar una suposición de más del consumer con el provider adversario-pero-legal (lección 6); y quién posee el contrato —el consumer, consumer-driven, el concepto de Pact— (lección 7). Sales sabiendo escribir, leer y correr un contrato desde sus dos sillas, y usarlo como la red que caza los cambios incompatibles antes de que lleguen a producción.

Hacia dónde sigue la guía. Hasta aquí trabajaste el contrato del repositorio aislado: verificaste que el fake y el real coinciden en lo que el contrato promete, y que el consumer se apoya solo en lo prometido. Pero un contrato verde por ambos lados garantiza que las piezas cumplen su acuerdo, no que funcionen juntas de verdad al conectarse. Ese es el siguiente escalón. El módulo 5 deja el contrato aislado y prueba los componentes reales juntosBookingService y SqliteBookingRepository conectados, cruzando la costura de verdad, con una prueba de integración de bookget—: qué mantener real y qué doblar en una integración, y cómo verificar que el ensamblaje completo funciona, no solo que cada pieza honra su contrato. El contrato garantizó que el fake no miente; la integración verifica que el edificio, con sus piezas reales, se sostiene.

Recursos