Módulo 3: Contract testing: el contrato consumer/provider

8. Mini-proyecto: escribe el contrato del `BookingRepository`

Descripción

Llegó el momento de poner las siete lecciones a trabajar con tus propias manos. En este mini-proyecto construyes, desde cero, el contrato del BookingRepository como una batería de tests parametrizada, lo corres contra el FakeBookingRepository y el SqliteBookingRepository para certificarlos —ambos verdes—, y luego metes el fake divergente del módulo 2 para verlo cazado en rojo. Al terminar tendrás, hecho por ti, el instrumento entero de este módulo: un spec de comportamiento compartido que convierte "espero que el fake no mienta" en "el fake no puede mentir sin que un test rojo lo delate".

No hay conceptos nuevos aquí; hay síntesis. Vas a usar todo: las cuatro cláusulas como comportamiento y no forma (lección 2), dirigidas por las necesidades del consumer (lección 3), corridas con una fixture parametrizada contra varias implementaciones (lección 4), cazando la divergencia del módulo 2 (lección 5), afirmando sobre el estado (lección 6). Es el mismo trabajo que la industria automatiza con Pact (lección 7), hecho a mano con pytest y sqlite3 de la stdlib. La entrega es concreta: la batería, la salida verde de ambos providers, y la salida roja del fake que miente.

Conexión con el módulo: esta lección es el cierre práctico del módulo 3 y su prueba de fuego. Si puedes escribir esta batería sin mirar, correrla contra dos providers y leer el resultado —incluido el rojo del fake divergente—, dominas el contrato como batería compartida, que es lo que el módulo prometía. También es la rampa al módulo 4: allí tomaremos este mismo contrato y lo miraremos desde sus dos lados —el test del consumer y el test del provider— para cazar un cambio incompatible antes de desplegarlo. Aquí lo dejas construido y funcionando.

El encargo

Escribe una batería de contrato para el BookingRepository de Reservo que cumpla estos requisitos:

  1. Cuatro cláusulas de comportamiento, cada una un test:
    • guardar y leer devuelve la misma reserva;
    • get de un id ausente lanza;
    • guardar dos veces el mismo id actualiza (no duplica);
    • find_by_room devuelve solo las reservas de esa sala.
  2. Una fixture parametrizada que entregue, en corridas distintas, un FakeBookingRepository y un SqliteBookingRepository sobre :memory:. Las cuatro cláusulas deben correr contra ambos sin duplicar código.
  3. La certificación en verde: corre la batería y confirma que las ocho combinaciones (4 cláusulas × 2 providers) pasan.
  4. La divergencia en rojo: crea una segunda batería (o cambia el provider) que use el BuggyFakeBookingRepository —el del módulo 2, cuyo get de un id ausente devuelve None en vez de lanzar— junto al real, y confirma que la batería lo caza: exactamente la cláusula del id ausente falla para el fake buggy y pasa para el real.

Usa Booking.price_cents para el dinero (centavos int), identificadores en inglés, y las anclas de Reservo (Focus 3 h pro = 6000 centavos). Ejecuta de verdad y guarda la salida.

Paso 1: las piezas del dominio

Ten a la mano las tres piezas que la batería usará. El modelo (una reserva), el fake correcto, el fake buggy y el repositorio real. Son las que vienes viendo todo el módulo:

# reservo/models.py — el dominio
from dataclasses import dataclass
from datetime import datetime


@dataclass
class Booking:
    id: str
    room_id: str
    member_id: str
    start: datetime
    end: datetime
    status: str          # "confirmed" | "cancelled"
    price_cents: int     # lo que se cobro, en centavos
# reservo/doubles.py — el fake correcto y el fake que mintio
class FakeBookingRepository:
    def __init__(self):
        self._store = {}

    def save(self, booking):
        self._store[booking.id] = booking

    def get(self, booking_id):
        return self._store[booking_id]       # LANZA KeyError si no existe

    def find_by_room(self, room_id):
        return [b for b in self._store.values() if b.room_id == room_id]


class BuggyFakeBookingRepository:
    def __init__(self):
        self._store = {}

    def save(self, booking):
        self._store[booking.id] = booking

    def get(self, booking_id):
        return self._store.get(booking_id)   # DEVUELVE None si no existe (bug del M2)

    def find_by_room(self, room_id):
        return [b for b in self._store.values() if b.room_id == room_id]

El SqliteBookingRepository es el de las lecciones anteriores: guarda en una tabla, convierte el datetime a texto ISO en save y de vuelta a datetime en get (para cumplir la cláusula 1), y lanza KeyError cuando la fila no existe (para cumplir la cláusula 2). No lo repetimos entero aquí; es el reservo/sqlite_repo.py que ya conoces.

Paso 2: la batería de contrato

Escribe la batería. Es el corazón del encargo: cuatro cláusulas y una fixture que las corre contra ambos providers.

# tests/test_repository_contract.py
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)      # Focus 3 h


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)


# La fixture parametrizada: cada clausula corre contra el fake Y contra SQLite.
@pytest.fixture(params=["fake", "sqlite"])
def repo(request):
    if request.param == "fake":
        return FakeBookingRepository()
    return SqliteBookingRepository(sqlite3.connect(":memory:"))


# Clausula 1: guardar-y-leer devuelve la misma reserva.
def test_save_then_get_returns_the_same_booking(repo):
    booking = a_booking()
    repo.save(booking)
    assert repo.get("bk-1") == booking


# Clausula 2: get de un id ausente lanza.
def test_get_of_a_missing_id_raises(repo):
    with pytest.raises(KeyError):
        repo.get("does-not-exist")


# Clausula 3: save dos veces del mismo id actualiza (no duplica).
def test_saving_the_same_id_twice_updates_not_duplicates(repo):
    repo.save(a_booking(status="confirmed"))
    repo.save(a_booking(status="cancelled"))     # mismo id "bk-1"
    assert repo.get("bk-1").status == "cancelled"
    assert len(repo.find_by_room("focus")) == 1


# Clausula 4: find_by_room devuelve solo las reservas de esa sala.
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"))
    found = repo.find_by_room("focus")
    assert [b.id for b in found] == ["bk-1"]

Paso 3: certifica el fake y el real (verde)

Corre la batería. Esperas ocho verdes: las cuatro cláusulas por los dos providers.

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.03s ==============================

Ocho verdes: el FakeBookingRepository y el SqliteBookingRepository cumplen las cuatro cláusulas del contrato. Eso es la certificación —el fake no miente sobre nada que el contrato cubra—, y es la primera mitad de tu entrega.

Paso 4: caza el fake divergente (rojo)

Ahora la parte que da sentido a todo: mete el fake buggy del módulo 2 y comprueba que la batería lo delata. Reusa las mismas cuatro cláusulas —el contrato no cambia—; solo cambia el provider "fake" por el buggy en la fixture:

# tests/test_contract_catches_divergence.py
import sqlite3
from datetime import datetime

import pytest

from reservo.doubles import BuggyFakeBookingRepository
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)


# El fake DIVERGENTE del modulo 2 junto al real.
@pytest.fixture(params=["buggy-fake", "sqlite"])
def repo(request):
    if request.param == "buggy-fake":
        return BuggyFakeBookingRepository()
    return SqliteBookingRepository(sqlite3.connect(":memory:"))


def test_save_then_get_returns_the_same_booking(repo):
    booking = a_booking()
    repo.save(booking)
    assert repo.get("bk-1") == booking


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"))
    assert repo.get("bk-1").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"))
    found = repo.find_by_room("focus")
    assert [b.id for b in found] == ["bk-1"]

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

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

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

=================================== FAILURES ===================================
_________________ test_get_of_a_missing_id_raises[buggy-fake] __________________

repo = <reservo.doubles.BuggyFakeBookingRepository object at 0x...>

    def test_get_of_a_missing_id_raises(repo):
>       with pytest.raises(KeyError):
E       Failed: DID NOT RAISE KeyError

tests/test_contract_catches_divergence.py:34: Failed
=========================== short test summary info ============================
FAILED tests/test_contract_catches_divergence.py::test_get_of_a_missing_id_raises[buggy-fake] - Failed: DID NOT RAISE KeyError
========================= 1 failed, 7 passed in 0.04s ==========================

Ahí está la segunda mitad de tu entrega, el rojo que corona el módulo. Un solo caso falla —test_get_of_a_missing_id_raises[buggy-fake]— con el mensaje inequívoco DID NOT RAISE KeyError; el mismo test pasa para [sqlite]. El id entre corchetes señala al culpable (el fake, no el real), la cláusula señala el comportamiento roto (el id ausente), y el mensaje señala la causa (no lanzó). La divergencia del módulo 2, que solo aparecía en producción, ahora es un rojo en tu máquina, con nombre, línea y motivo. Eso es el contrato haciendo su trabajo.

La entrega

Reúne y revisa que tengas:

  1. La batería de contrato (test_repository_contract.py): cuatro cláusulas de comportamiento y una fixture parametrizada ["fake", "sqlite"].
  2. La salida verde: 8 passed, con los ids [fake] y [sqlite] para cada cláusula. Certifica que el fake y el real cumplen el contrato.
  3. La batería que caza la divergencia (test_contract_catches_divergence.py): las mismas cuatro cláusulas con el BuggyFakeBookingRepository en lugar del fake correcto.
  4. La salida roja: 1 failed, 7 passed, con test_get_of_a_missing_id_raises[buggy-fake] FAILED — DID NOT RAISE KeyError. Demuestra que el contrato caza el fake que miente.
  5. Un diagnóstico de tres líneas: qué cláusula falló, contra qué provider, y por qué —y cuál sería el arreglo correcto (alinear el fake al comportamiento que el consumer necesita: que get ausente lance), sin degradar el contrato ni tocar al provider sano—.

Errores comunes

Entregar solo el verde. Qué pasa: se corre la batería, sale 8 passed, y se da por terminado el proyecto. Por qué pasa: el verde se siente como "listo". Cómo detectarlo: si no tienes un rojo, no demostraste que el contrato sirva —una batería que solo has visto pasar podría estar vacía de contenido y pasar igual—. Cómo corregirlo: el rojo del fake divergente es la mitad del encargo, no un extra. Un contrato se demuestra tanto por lo que certifica (el verde) como por lo que caza (el rojo). Sin el rojo, no probaste que la batería detecte algo.

Duplicar la batería en vez de parametrizar. Qué pasa: para probar el fake y el real, se escriben dos archivos con los mismos tests copiados. Por qué pasa: es el primer instinto —dos providers, dos suites—. Cómo detectarlo: si añadir una cláusula te obliga a editarla en dos lugares, duplicaste. Cómo corregirlo: una fixture con params corre las mismas cláusulas contra ambos. Es lo que garantiza que no puedan divergir —hay un solo spec—, y es explícitamente lo que el encargo pide (requisito 2).

Cerrar el rojo del paso 4 de la forma equivocada. Qué pasa: para "arreglar" el 1 failed, alguien hace que el SqliteBookingRepository devuelva None en get ausente, o borra la cláusula. Por qué pasa: un rojo incomoda. Cómo detectarlo: si tu arreglo hace que el contrato tolere el comportamiento que el consumer no quiere, lo degradaste. Cómo corregirlo: el rojo del paso 4 debe quedar rojo —es la demostración de que la batería caza la divergencia—. El fake buggy está mal a propósito; el arreglo real (fuera de este mini-proyecto) sería corregir el fake para que lance, pero aquí el objetivo es verlo cazado, no silenciarlo.

Ejercicios

Ejercicio 1 — Añade una quinta cláusula. El consumer necesita que find_by_room de una sala sin reservas devuelva una lista vacía, no que lance ni devuelva None. Escribe la cláusula como test parametrizado y di, sin correrla, si el fake y el real la pasan.

Ver solución
# Clausula 5: find_by_room de una sala sin reservas devuelve lista vacia.
def test_find_by_room_of_empty_room_returns_empty_list(repo):
    result = repo.find_by_room("nonexistent-room")
    assert result == []

La pasan los dos. El FakeBookingRepository.find_by_room es [b for b in self._store.values() if b.room_id == room_id]: sobre un dict vacío (o sin reservas de esa sala) la comprensión de lista devuelve []. El SqliteBookingRepository.find_by_room hace un SELECT ... WHERE room_id = ? y un fetchall(): sin filas que coincidan, fetchall() devuelve una lista vacía, así que también devuelve []. Ambos cumplen: la batería quedaría en 10 passed (5 cláusulas × 2 providers).

La lección: esta cláusula cubre un borde (la sala vacía) que el contrato callaba. Aunque hoy ambos providers ya lo cumplen "por casualidad" de cómo están escritos, hacerlo cláusula lo vuelve una promesa vigilada: si mañana alguien escribe un provider cuyo find_by_room devuelve None para una sala vacía, la batería lo cazaría. Cada borde que enuncias es una puerta que cierras.

Ejercicio 2 — Un segundo fake divergente. Escribe un NoUpdateFakeBookingRepository cuyo save inserta pero nunca actualiza (si el id ya existe, agrega una segunda copia en una lista interna en vez de reemplazar). Mételo en la batería junto al real. Sin correr, di cuál cláusula lo caza y con qué id.

Ver solución
class NoUpdateFakeBookingRepository:
    def __init__(self):
        self._items = []            # lista, no dict: permite duplicados por id

    def save(self, booking):
        self._items.append(booking)                 # SIEMPRE agrega, nunca reemplaza

    def get(self, booking_id):
        for b in reversed(self._items):
            if b.id == booking_id:
                return b
        raise KeyError(booking_id)

    def find_by_room(self, room_id):
        return [b for b in self._items if b.room_id == room_id]

La cláusula que lo caza es la 3: test_saving_the_same_id_twice_updates_not_duplicates. Ese test guarda bk-1 dos veces (confirmada, luego cancelada) y afirma dos cosas: que get("bk-1").status == "cancelled" y que len(repo.find_by_room("focus")) == 1. El NoUpdateFakeBookingRepository guardaría dos copias de bk-1, así que find_by_room("focus") devolvería una lista de longitud 2, y la aserción == 1 fallaría. (La primera aserción del test podría pasar, porque get devuelve la última copia, cancelada; pero la segunda, sobre el conteo, delata el duplicado.)

El id del rojo sería test_saving_the_same_id_twice_updates_not_duplicates[no-update-fake], mientras [sqlite] pasa —el real usa ON CONFLICT(id) DO UPDATE, así que actualiza en vez de duplicar—. Cada tipo de divergencia enciende su cláusula: el None en get ausente encendía la 2, el no-actualizar enciende la 3. Un contrato bien poblado de cláusulas es una red con un nudo por cada comportamiento que importa.

Ejercicio 3 — El diagnóstico completo. Corriste el paso 4 y obtuviste 1 failed, 7 passed con test_get_of_a_missing_id_raises[buggy-fake] FAILED. Escribe el diagnóstico de tres líneas que pide la entrega (qué falló, contra qué provider, por qué) y el arreglo correcto, conectándolo con el enfoque consumer-driven.

Ver solución

Diagnóstico:

  • Qué falló: la cláusula 2 del contrato, test_get_of_a_missing_id_raises —"get de un id ausente debe lanzar KeyError"—.
  • Contra qué provider: el [buggy-fake] (el BuggyFakeBookingRepository). El mismo test pasa para [sqlite], así que el real cumple la cláusula y sirve de referencia correcta.
  • Por qué: el get del fake buggy hace self._store.get(booking_id), que devuelve None cuando la clave no existe, en vez de self._store[booking_id], que lanza KeyError. pytest.raises(KeyError) no vio la excepción esperada y reportó DID NOT RAISE KeyError.

El arreglo correcto: alinear el fake al comportamiento que el consumer necesita. BookingService.cancel hace booking = self._repo.get(booking_id) y luego refund_cents(booking, ...); si get devuelve None, cancel estalla con un AttributeError más adelante, así que el consumer necesita que get ausente lance para reaccionar limpio. Se cambia self._store.get(booking_id) por self._store[booking_id] en el fake, y la batería vuelve a los ocho verdes. Lo que no se hace: degradar el contrato (borrar la cláusula, aceptar None) ni tocar al SqliteBookingRepository, que ya cumple —arreglar el provider sano introduciría en producción justo el bug que el contrato acaba de cazar—. El comportamiento correcto lo dicta el consumer; el contrato lo exige a todos; el provider que se desvía es el que se corrige.

Resumen del módulo y siguiente paso

Con este mini-proyecto cerraste el módulo 3 habiendo construido, con tus manos, el instrumento central de la disciplina: el contrato como batería compartida. Recorriste las siete lecciones que llevan a él. Empezaste (lección 1) tomando el problema del módulo 2 —el fake que miente— y nombrando su cura: un spec de comportamiento verificado igual contra todas las implementaciones. Separaste (lección 2) la interfaz (la forma) del contrato (el comportamiento), la grieta por la que se colaba el bug. Descubriste (lección 3) que manda el consumer: sus necesidades son las cláusulas. Dominaste (lección 4) el mecanismo —la fixture parametrizada que corre una batería contra el fake y SQLite— y (lección 5) lo viste cazar la divergencia del módulo 2 en rojo, con el id [buggy-fake] señalando al culpable. Distinguiste (lección 6) los contratos de estado (afirmar sobre el resultado) de los de interacción (afirmar sobre la llamada), y cuándo va cada uno. Y situaste todo (lección 7) dentro del panorama de la industria con el concepto de Pact —el mismo patrón, automatizado y en red, con su pact file y su broker—.

Lo que te llevas del módulo, en una frase: un contrato es un spec de comportamiento consumer-driven que, corrido como una batería única contra todas las implementaciones, hace imposible que un doble mienta sin que un test rojo lo delate.

El siguiente paso profundiza en algo que aquí tratamos como una sola cosa: los dos lados del contrato. En el módulo 4, Verificar el contrato desde los dos lados, separamos el test del consumer ("yo, BookingService, mando esto y espero aquello") del test del provider ("yo, el repositorio, dado esto devuelvo aquello"), corremos la misma batería contra el fake y el real desde cada lado, y usamos el contrato para cazar un cambio incompatible antes de desplegarlo —la superpotencia que en la lección 3 apenas asomó—. Llevas el contrato construido; el módulo 4 te enseña a mirarlo desde ambas orillas.

Recursos