Módulo 8: Proyecto: contrato + integración de Reservo
2. Escribir el contrato consumer-driven del repositorio
Descripción
Empieza la construcción de la entrega, y el primer entregable es el contrato. En esta lección lo escribes con tus manos, de principio a fin: las cuatro cláusulas y la fixture parametrizada que las correrá contra el fake y contra el real. Pero antes de teclear una sola aserción, hay dos decisiones de método que definen si tu contrato sirve o estorba, y son las que el capstone evalúa: cuál costura merece el contrato y de dónde salen sus cláusulas. Un contrato mal elegido —sobre la costura equivocada, o lleno de detalles que nadie usa— es peor que ninguno: da falsa confianza y frena cada cambio. Un contrato bien elegido es el pliego exacto de lo que dos componentes se prometen.
La regla que gobierna las cláusulas es la del módulo 3, y la vas a aplicar hasta que sea reflejo: manda el consumer. Las cláusulas no las inventa el que implementa el repositorio; las dictan las necesidades reales de BookingService, el que lo usa. Cada línea de BookingService que toca el repositorio es una necesidad, y cada necesidad reclama una cláusula. Por eso el contrato es pequeño y honesto: solo entra lo que el consumer de verdad necesita, ni el catálogo entero del provider ni los detalles internos de SQLite. En esta lección haces ese trabajo explícito —del uso a la cláusula— y dejas la batería escrita y lista para verificarla, en las dos lecciones siguientes, contra el fake y contra el real.
Conexión con el módulo: esta lección produce el entregable 1 y sienta la base de los otros dos. El contrato que escribes aquí es el que las lecciones 3 y 4 corren contra el fake y el real (entregable 2), y el mismo que en la lección 7 cazará un breaking change. Aquí no lo ejecutas todavía a fondo —solo confirmas con --collect-only que la batería se expande como debe—; ejecutarlo en verde es el trabajo de las lecciones que siguen. Lo que dejas listo es el instrumento: cuatro cláusulas derivadas de necesidades reales del consumer, montadas en una fixture parametrizada que no duplica una línea.
Analogía: la carta de requisitos del comprador de casa
Cuando compras una casa, antes de firmar contratas a un inspector. Y aquí hay dos maneras de trabajar. En la mala, el inspector llega con su lista genérica —revisa cientos de cosas que traía de fábrica, muchas que a ti no te importan— y te entrega un informe de doscientas páginas donde lo que de verdad te preocupa (¿las tuberías aguantan?, ¿el techo filtra?) está enterrado entre datos que nunca usarás. En la buena, tú, el comprador, le entregas una carta de requisitos: "necesito que la instalación eléctrica soporte el consumo de una cocina moderna, que ninguna tubería gotee, que el techo no filtre en época de lluvia, y que las ventanas cierren". El inspector verifica eso, y solo eso, con una prueba concreta para cada punto. La lista es corta, es tuya, y cada línea existe porque a ti te importa.
El contrato consumer-driven es la carta de requisitos, y BookingService es el comprador. No le dices al repositorio cómo guardar —si con un dict, una tabla o un archivo, eso es libertad del provider, como el inspector es libre de usar el instrumento que quiera—; le dices qué necesitas poder confiar: que al guardar y leer recuperes la misma reserva, que pedir una ausente falle, que guardar dos veces actualice, que listar una sala devuelva lo suyo. Cada requisito sale de algo que BookingService de verdad hace. Y por eso el contrato se queda pequeño: no incluye "el repositorio asigna un rowid interno" ni "usa un índice B-tree", porque el comprador no lo pidió —no lo usa—. La carta corta y precisa del comprador es un contrato; el informe genérico de doscientas páginas es ruido.
Decisión 1: cuál costura, y por qué el repositorio
Reservo tiene dos costuras donde BookingService habla con un colaborador que tiene un doble y una implementación real: el BookingRepository (fake contra SQLite) y el PaymentGateway (stub contra un servicio de pagos). Un contrato tiene sentido justo ahí, donde un doble y un real pueden divergir. El capstone elige el repositorio, y la razón es que ahí el riesgo de divergencia es más rico y más peligroso:
- El repositorio serializa: el
datetimese guarda como texto y hay que reconstruirlo. El fake en memoria nunca serializa, así que puede ocultar ese bug. - El repositorio tiene estados de error con los que el consumer cuenta:
getde un id ausente debe lanzar para quecancelreaccione. Un fake puede devolverNonepor descuido. - El repositorio persiste con transacciones: el
commitdecide si una escritura sobrevive. El fake no tiene commits. - El repositorio actualiza: guardar dos veces el mismo id debe reemplazar, no duplicar. Un fake mal escrito podría acumular copias.
Cada uno de esos comportamientos es una cláusula candidata, y cada uno es un lugar donde el fake y el real pueden separarse sin que un unit test lo note. Por eso el repositorio es la costura que mejor enseña —y mejor protege—. El gateway también podría tener su contrato (lo verás en los ejercicios), pero su divergencia es menos variada, así que como pieza de práctica y como protección, el repositorio gana.
Decisión 2: de la necesidad del consumer a la cláusula
Ahora el trabajo consumer-driven. Mira lo que BookingService de verdad hace con el repositorio, y deja que cada uso reclame su cláusula:
# reservo/services.py — el CONSUMER (extracto de lo que toca el repositorio)
class BookingService:
def book(self, room, member, start, end):
existing = self._repo.find_by_room(room.id) # (D) necesita LISTAR una sala
# ...valida disponibilidad, cobra...
self._repo.save(booking) # (A) necesita GUARDAR y recuperar
return booking
def cancel(self, booking_id) -> int:
booking = self._repo.get(booking_id) # (B) necesita LEER, y que falle si no existe
# ...calcula reembolso...
booking.status = "cancelled"
self._repo.save(booking) # (C) necesita ACTUALIZAR sin duplicar
return refund
Cada línea que toca el repositorio es una necesidad concreta, y cada necesidad reclama exactamente una cláusula:
- (A)
bookguarda ycancelespera recuperar esa misma reserva. Si guardar y leer no devolvieran la misma reserva,cancelcalcularía mal el reembolso. → Cláusula 1: guardar-y-leer devuelve la misma reserva. - (B)
cancellee, y si el id no existe necesita enterarse. SigetdevolvieraNone, la línea siguiente estallaría con un error críptico. El consumer necesita quegetlance para reaccionar limpio. → Cláusula 2:getde un id ausente lanza. - (C)
cancelguarda de nuevo la misma reserva, ahora cancelada. Es el mismo id; el consumer necesita que esto actualice el estado, no que cree una segunda reserva fantasma. → Cláusula 3:savedos veces del mismo id actualiza, no duplica. - (D)
booklista las reservas de una sala para chequear disponibilidad. Necesita quefind_by_roomdevuelva todas y solo las reservas de esa sala, sin arrastrar las de otras. → Cláusula 4:find_by_roomdevuelve solo las reservas de esa sala.
Fíjate en lo que no aparece. No hay cláusula sobre el nombre de una columna, ni sobre un rowid, ni sobre qué índice usa SQLite. Nada de eso lo toca BookingService, así que nada de eso entra al contrato. El contrato es el retrato exacto de lo que el consumer usa —el borde entre los dos componentes visto desde el lado del que llama—.
La batería, escrita
Con las cuatro cláusulas derivadas, escribes la batería. Necesitas un ayudante que fabrique una reserva de ejemplo (con las anclas de Reservo: Focus 3 h para Ana pro, 6000 centavos) y la fixture parametrizada que entrega, en corridas distintas, el fake y el real.
# tests/test_repository_contract.py — EL CONTRATO (entregable 1)
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 real.
@pytest.fixture(params=["fake", "sqlite"])
def repo(request):
if request.param == "fake":
return FakeBookingRepository()
return SqliteBookingRepository(sqlite3.connect(":memory:"))
# Clausula 1 (necesidad A): 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 (necesidad B): 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 (necesidad C): 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 (necesidad D): 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"]
Cada cláusula está escrita en términos de la interfaz pública —save, get, find_by_room—, nunca de la implementación. Por eso el fake (un dict) y el real (una tabla) pueden cumplirlas los dos: ninguna cláusula dice "existe una fila en la tabla", que solo tendría sentido para SQLite. Esa disciplina es lo que hace que el contrato sea compartido.
Ejemplo trabajado: confirma la expansión con --collect-only
Antes de correr las aserciones, conviene verificar que la batería se expande como debe: cuatro cláusulas por dos providers, ocho casos. Pytest puede recolectar los tests sin ejecutarlos, con --collect-only:
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_repository_contract.py --collect-only -q
tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[fake]
tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[sqlite]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[fake]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[sqlite]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[fake]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[sqlite]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[fake]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[sqlite]
8 tests collected in 0.01s
Escribiste cuatro funciones de test; pytest recolectó ocho casos. Cada cláusula aparece dos veces, una con [fake] y otra con [sqlite], porque la fixture parametrizada la corre una vez por cada valor de params. El texto entre corchetes es el id del parámetro, tu mapa para leer, cuando algo falle, contra qué provider falló. La cuenta es simple y vale tenerla clara: casos = cláusulas × implementaciones. El contrato está escrito y bien formado; en la lección 3 lo corremos contra el fake, y en la 4 contra el real.
Errores comunes
Dejar que el provider dicte las cláusulas. Qué pasa: escribes el contrato mirando lo que el SqliteBookingRepository hace hoy —"prometemos que find_by_room devuelve en orden de rowid", "los ids son de tal formato"— en vez de lo que BookingService necesita. Por qué pasa: la implementación es lo que tienes a la mano; describir tu código es más fácil que preguntar qué usa el consumer. Cómo detectarlo: si una cláusula menciona un detalle que ningún método de BookingService usa, la dictó el provider. Cómo corregirlo: para cada cláusula pregúntate "¿qué línea de BookingService se rompería si esto no se cumpliera?". Si la respuesta es "ninguna", la cláusula sobra. El contrato son las necesidades del consumer, no el inventario del provider.
Escribir cláusulas que solo una implementación puede cumplir. Qué pasa: una cláusula afirma sobre cómo guarda el provider —"tras save, existe una fila en la tabla bookings"—. Por qué pasa: es tentador verificar lo que se ve fácil desde adentro de SQLite. Cómo detectarlo: si la cláusula no tiene sentido para el fake (que no tiene tabla), no es un contrato compartido —el fake no podría cumplirla ni queriendo—. Cómo corregirlo: escribe cada cláusula solo con la interfaz pública. "Tras save(b), get(b.id) devuelve b" lo cumplen el dict y la tabla; "existe una fila" no. Un contrato que un provider legítimo no puede cumplir por su tecnología no es un contrato, es un sesgo hacia una implementación.
Inflar el contrato "por si acaso". Qué pasa: se añaden cláusulas sobre comportamientos que ningún consumer usa hoy —"save devuelve el id asignado", "find_by_room viene ordenado por fecha"— pensando que más cobertura es mejor. Por qué pasa: parece prudente prometer de más. Cómo detectarlo: si al cambiar el provider una cláusula se pone roja pero ningún consumer se rompería en producción, esa cláusula protege algo que a nadie le importa y frena cambios legítimos. Cómo corregirlo: el contrato consumer-driven es exactamente tan grande como el uso real. Si mañana un consumer de verdad necesita el orden, entonces se añade la cláusula, dirigida por esa necesidad. Antes no. Un contrato inflado envejece hasta volverse una carga que nadie se atreve a tocar.
Ejercicios
Ejercicio 1 — Una necesidad nueva, una cláusula nueva. Aparece una pantalla que lista las reservas de una sala que puede no tener ninguna, y necesita que find_by_room de una sala vacía devuelva una lista vacía, no None ni una excepción. Escribe la cláusula como test parametrizado y di, sin correrla, si el fake y el real la pasan.
Ver solución
# Clausula 5 (necesidad: listar una sala que puede estar vacia): lista vacia, no None.
def test_find_by_room_of_empty_room_returns_empty_list(repo):
assert repo.find_by_room("nonexistent-room") == []
La pasan los dos. El FakeBookingRepository.find_by_room es una comprensión de lista sobre el dict: sin reservas de esa sala, 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 []. La batería quedaría en 10 passed (5 cláusulas × 2 providers).
Lo importante es de dónde salió la cláusula: de una necesidad real del consumer (una pantalla que lista una sala que puede estar vacía), no de un capricho. Aunque hoy ambos providers ya la cumplen "por casualidad" de cómo están escritos, hacerla cláusula la 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 —dirigido por una necesidad— es una puerta que cierras.
Ejercicio 2 — La cláusula que sobra. El equipo del SqliteBookingRepository propone añadir: "cláusula: save asigna a cada reserva un rowid incremental accesible por repo.last_rowid()". Ningún método de BookingService llama a last_rowid(). ¿Debe entrar al contrato consumer-driven? Justifica con las dos pruebas que la descartan.
Ver solución
No debe entrar, y dos pruebas lo confirman:
- La prueba del consumer: ningún consumer —
BookingServiceni la pantalla— llama alast_rowid()ni depende de ningúnrowid. La cláusula la dicta el provider (habla de un detalle interno de SQLite), no una necesidad real. Pregunta de control: "¿qué línea deBookingServicese rompería si esto no se cumpliera?". Ninguna. La cláusula sobra. - La prueba de "cumplible por cualquier provider": el
FakeBookingRepository(undict) no tienerowidnilast_rowid(). Si esta cláusula entrara al contrato compartido, el fake no podría cumplirla —fallaría no por un bug, sino porque la cláusula pide algo que solo tiene sentido para una implementación—. Eso rompe la premisa del contrato compartido.
La cláusula se queda fuera. Si algún día un consumer real necesitara un identificador incremental, entonces se añadiría —dirigida por esa necesidad, expresada en términos que cualquier provider pueda cumplir—, no porque el provider lo tenga a la mano.
Ejercicio 3 — Un contrato para el gateway. Supón que decides además escribir un contrato para el PaymentGateway. BookingService.book hace self._payments.charge(amount_cents) y espera un Receipt con ok=True cuando el cobro procede. Escribe la necesidad del consumer y la cláusula que reclama, y di qué providers correría (piensa en el stub y en un futuro gateway real).
Ver solución
La necesidad del consumer: book cobra con charge(amount_cents) y necesita saber si el cobro procedió para decidir si confirma la reserva. Depende de que charge devuelva un Receipt cuyo ok diga la verdad —True si el cobro pasó, y de que un cobro rechazado se distinga de uno exitoso (por ejemplo, ok=False o una excepción), no que devuelva None ambiguo—.
La cláusula que reclama:
@pytest.fixture(params=["stub", "real"])
def gateway(request):
if request.param == "stub":
return StubPaymentGateway(ok=True)
return RealPaymentGateway(...) # el gateway real, en modo sandbox
def test_successful_charge_returns_a_receipt_marked_ok(gateway):
receipt = gateway.charge(6000)
assert receipt.ok is True
assert receipt.amount_cents == 6000
Qué providers correría: el StubPaymentGateway (el doble que ya usan los tests) y un RealPaymentGateway contra el servicio real, idealmente en su modo de pruebas (sandbox). El valor del contrato aparece justo cuando esas dos implementaciones pueden divergir: si el stub devuelve un Receipt(ok=True) limpio pero el real, ante cierto monto, devuelve None o lanza una excepción que book no maneja, el contrato lo cazaría antes de que un cobro roto llegue a producción. La técnica es idéntica a la del repositorio: cláusulas dirigidas por las necesidades del consumer, corridas contra el doble y el real. Lo que cambia es la costura; el método, no.
Resumen y siguiente paso
En esta lección escribiste el primer entregable del capstone: el contrato consumer-driven del BookingRepository. Antes de teclear tomaste las dos decisiones de método que lo hacen útil: elegiste la costura del repositorio —porque ahí la divergencia fake-vs-real es más rica y peligrosa (serialización, estados de error, transacciones, actualización)— y derivaste cada cláusula de una necesidad concreta de BookingService, no del catálogo del provider. Con la carta de requisitos del comprador de casa fijaste la imagen: el contrato es corto, es del consumer, y cada línea existe porque el consumer la usa. Escribiste las cuatro cláusulas en términos de la interfaz pública, montaste la fixture parametrizada, y confirmaste con --collect-only que la batería se expande a ocho casos.
Antes de avanzar deberías poder: elegir la costura de un contrato justificando por qué corre riesgo de divergir; traducir un uso concreto del repositorio en BookingService a la cláusula que reclama; y descartar del contrato lo que solo el provider quiere (detalles internos, promesas que nadie usa).
El contrato está escrito, pero escrito no es verificado. En la lección 3 lo corres contra el FakeBookingRepository y ves los cuatro casos [fake] en verde —y, más importante, entiendes por qué ese verde, solo, todavía no prueba nada—. Es el primer lado del segundo entregable, y la puerta al segundo lado, el real, que es donde el contrato empieza a valer.
Recursos
- Documentación de pytest — Parametrizando fixtures y funciones de test — la referencia de la fixture con
paramsy del objetorequest, el mecanismo que corre tu contrato contra el fake y contra SQLite sin duplicar una línea. - Documentación de pytest —
--collect-only— cómo pedirle a pytest que recolecte los tests sin ejecutarlos, para confirmar que la batería se expande a los ocho casos que esperas. - docs.pact.io — El enfoque consumer-driven — la premisa que aplicas al derivar cada cláusula: el consumer define el contrato y el provider se verifica contra él; la referencia industrial de lo que armas a mano.
test-doubles-and-test-data-guide— la guía hermana donde construiste elFakeBookingRepositoryy sus colaboradores; útil para recordar la costura consumer/provider antes de formalizarla como contrato.