Módulo 8: Proyecto: contrato + integración de Reservo
8. Proyecto: contrato + integración de Reservo
Descripción
Llegó el momento de reunir todo en una entrega formal. Durante siete lecciones construiste, pieza por pieza, las tres capas de la garantía; esta lección las junta en un proyecto con enunciado, rúbrica y solución de referencia, y con él cierra la guía. El encargo es concreto: entregar un contrato consumer-driven para el BookingRepository, su verificación desde ambos lados —contra el fake y contra el SQLite real, ambos verdes—, y una prueba de integración de punta a punta de BookingService + repositorio real, aislada, que ejercita book→get→cancel. No hay conceptos nuevos: hay síntesis, criterio y ejecución real.
Lo que se evalúa, y conviene decirlo sin rodeos, es el método, no la cantidad. Un proyecto con seis tests bien elegidos —cada uno con una razón clara de existir— vale más que uno con cincuenta escritos por inercia. La rúbrica de esta lección mide si sabes decidir qué doblar y qué probar real, derivar las cláusulas de las necesidades del consumer, verificar desde ambos lados, aislar la integración, y explicar por qué cada pieza está donde está. Al final tendrás el proyecto entero corriendo en verde y la guía cerrada, con la respuesta a la pregunta que la abrió: cuáles tests sirven, y por qué.
Conexión con el módulo y la guía: esta lección es el cierre del capstone y del recorrido completo. Recoge los entregables de las lecciones 2 a 6 y la prueba de valor de la lección 7, los presenta como un encargo formal con su rúbrica, y ofrece una solución de referencia completa —con su salida real de pytest— contra la cual contrastar tu trabajo. Y termina donde termina la guía: un repaso de los ocho módulos y el mapa de a dónde seguir en el ecosistema de Testing. Es tu prueba de fuego y tu graduación.
El encargo
Escribe, para el Reservo de esta guía, las tres piezas siguientes, ejecútalas de verdad con pytest y sqlite3 de la stdlib, y guarda la salida de cada una.
Entregable 1 — El contrato consumer-driven del BookingRepository. Una batería de tests parametrizada con, como mínimo, estas cuatro cláusulas, cada una derivada de una necesidad real de BookingService:
- guardar y leer devuelve la misma reserva (necesidad:
bookguarda,cancelrelee); getde un id ausente lanza (necesidad:canceldebe enterarse si la reserva no existe);- guardar dos veces el mismo id actualiza, no duplica (necesidad:
cancelreescribe la reserva cancelada); find_by_roomdevuelve solo las reservas de esa sala (necesidad:bookchequea disponibilidad, una pantalla lista la sala).
Entregable 2 — La verificación desde ambos lados. Una fixture parametrizada que corre la batería, sin duplicar código, contra el FakeBookingRepository y el SqliteBookingRepository real sobre :memory:. La entrega es la salida verde de las ocho combinaciones (4 cláusulas × 2 providers).
Entregable 3 — La integración de punta a punta. Una prueba de BookingService + SqliteBookingRepository real que ejercita el flujo completo book→get→cancel, con la mezcla correcta de real (el repositorio) y doblado (reloj, pago, correo), aislada con una fixture que crea y destruye el recurso o con una transacción que se revierte. La entrega es la salida verde del flujo.
Usa Booking.price_cents para el dinero (centavos int), identificadores en inglés, y las anclas de Reservo (Focus 3 h pro = 6000 centavos; reembolso 72 h→6000 / 36 h→3000 / 12 h→0).
La rúbrica: se evalúa el método
Tu entrega se juzga por cinco criterios, todos sobre el método. No hay puntos por volumen de tests.
| Criterio | Qué se busca | Señal de que está bien |
|---|---|---|
| Decisión | Elegiste qué doblar y qué mantener real, y sabes justificarlo | Real el repositorio (la costura probada); doblados reloj, pago y correo (no determinista, externo), con la razón dicha |
| Contrato consumer-driven | Las cláusulas salen de necesidades reales de BookingService, no del catálogo del provider | Cada cláusula se puede rastrear a una línea de book o cancel; ninguna menciona detalles internos de SQLite |
| Ambos lados | El contrato corre contra el fake Y el real, con una sola batería | 8 passed con ids [fake] y [sqlite], no dos suites gemelas ni un solo lado |
| Integración aislada | El flujo real cruza la costura y cada corrida empieza limpia | El flujo book→get→cancel verde, con fixture o rollback; no se contamina entre tests |
| Explicación | Puedes decir, para cada pieza, por qué existe y qué bug caza | Un diagnóstico corto: qué doblaste y por qué, qué caza el contrato, qué caza la integración |
La forma de suspender esta rúbrica no es escribir pocos tests: es escribir muchos sin criterio —doblar la costura que se quiere probar, dejar el contrato de un solo lado, no aislar la integración, o no saber decir por qué cada test existe—. La forma de aprobarla es entregar las tres piezas, verdes, y explicar el método detrás de cada una.
Solución de referencia
Contrasta tu trabajo con esta solución completa. Está en un desplegable para que primero intentes la tuya; ábrela cuando quieras comparar.
Ver la solución de referencia completa (código y salida real)
Entregables 1 y 2: el contrato desde ambos lados
# 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)
@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): # clausula 1
booking = a_booking()
repo.save(booking)
assert repo.get("bk-1") == booking
def test_get_of_a_missing_id_raises(repo): # clausula 2
with pytest.raises(KeyError):
repo.get("does-not-exist")
def test_saving_the_same_id_twice_updates_not_duplicates(repo): # clausula 3
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): # clausula 4
repo.save(a_booking(id="bk-1", room_id="focus"))
repo.save(a_booking(id="bk-2", room_id="studio"))
assert [b.id for b in repo.find_by_room("focus")] == ["bk-1"]
Salida real:
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 ==============================
Entregable 3: la integración de punta a punta, aislada
# tests/test_end_to_end_integration.py
import sqlite3
from datetime import datetime
import pytest
from reservo.calendar import Calendar
from reservo.doubles import 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)
CLOCK = datetime(2026, 3, 1, 9) # 9 dias antes -> reembolso completo (6000)
@pytest.fixture
def repo():
conn = sqlite3.connect(":memory:") # base efimera, limpia por test (aislamiento)
yield SqliteBookingRepository(conn)
conn.close()
def make_service(repo):
# Real la costura probada (repositorio); doblado lo no determinista y externo.
return BookingService(Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo)
def test_book_then_get_persists_the_booking(repo):
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
saved = repo.get(booking.id)
assert saved.price_cents == 6000
assert saved.start == START
assert saved.status == "confirmed"
def test_book_cancel_get_full_flow_against_real_sqlite(repo):
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
refund = service.cancel(booking.id)
assert refund == 6000
assert repo.get(booking.id).status == "cancelled"
Salida real:
python3 -m pytest tests/test_end_to_end_integration.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items
tests/test_end_to_end_integration.py::test_book_then_get_persists_the_booking PASSED [ 50%]
tests/test_end_to_end_integration.py::test_book_cancel_get_full_flow_against_real_sqlite PASSED [100%]
============================== 2 passed in 0.01s ==============================
El diagnóstico del método (parte de la entrega)
- Qué doblé y por qué: el reloj (
FixedClock), porque es no determinista y quiero fijar la anticipación que hace el reembolso6000; el pago (StubPaymentGateway) y el correo (SpyEmailSender), porque son externos y con efectos que no quiero en un test. Qué dejé real: elBookingRepository(fake y sqlite en el contrato, sqlite en la integración), porque es la costura que estas pruebas existen para verificar. - Qué caza el contrato: las divergencias en los comportamientos que enumeré como cláusulas —si el real deja de lanzar en un id ausente, o de reconstruir el
datetime, o empieza a duplicar—. Cubre lo que escribí. - Qué caza la integración: los bugs de uso que el contrato no ejercita —si
cancelno pudiera operar sobre elstartque el repositorio devuelve—. Cubre lo que el flujo real desencadena aunque no lo enumeré. - Cómo aislé: fixture
repode scopefunctioncon:memory:, que da una base nueva y vacía por test y la cierra al terminar; cada corrida empieza limpia, sin contaminación entre tests.
Prueba de valor (opcional, recomendada): el contrato caza un breaking change
Para demostrar que el contrato sirve, cambia el SqliteBookingRepository.get para que devuelva None en vez de lanzar, y corre la batería. Debe ponerse rojo exactamente en la cláusula 2, lado [sqlite]:
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[sqlite] FAILED [ 50%]
...
E Failed: DID NOT RAISE KeyError
========================= 1 failed, 7 passed in 0.04s ==========================
1 failed, 7 passed: el contrato caza el breaking change antes del deploy, nombrando la cláusula, el provider ([sqlite]) y el síntoma (DID NOT RAISE KeyError). El [fake] de esa cláusula sigue verde, señalando que el que se desvió es el provider real. Ese rojo es la demostración de que la batería no está vacía: caza lo que debe cazar.
Errores comunes
Entregar solo el verde y no el método. Qué pasa: se corren las tres piezas, salen en verde, y se da por terminado sin el diagnóstico. Por qué pasa: el verde se siente como "listo". Cómo detectarlo: si no puedes escribir, para cada test, por qué existe y qué bug caza, te falta la mitad que la rúbrica evalúa. Cómo corregirlo: acompaña la salida verde con el diagnóstico del método —qué doblaste y por qué, qué caza el contrato, qué caza la integración, cómo aislaste—. La entrega no es la salida de pytest sola; es la salida más la explicación de las decisiones.
Entregar el contrato de un solo lado. Qué pasa: se corre la batería con -k fake (o solo contra SQLite) y se entrega 4 passed. Por qué pasa: un lado es más rápido, y el filtro se quedó de una lección anterior. Cómo detectarlo: si tu salida dice deselected, filtraste; el entregable 2 pide los dos lados. Cómo corregirlo: corre la batería entera, sin -k, y entrega los 8 passed con [fake] y [sqlite]. Un contrato de un solo lado no es un contrato —es un test de esa implementación—, y la rúbrica lo marca como incompleto.
Doblar la costura probada o no aislar la integración. Qué pasa: se usa FakeBookingRepository en la "integración", o se comparte una base con commit entre tests. Por qué pasa: doblar todo y compartir el recurso son reflejos que ahorran esfuerzo a corto plazo. Cómo detectarlo: si en tu integración ninguna pieza real cruza la costura, no es integración; si un test falla al correr junto a otro pero pasa solo, no está aislado. Cómo corregirlo: real el repositorio (es lo que pruebas), doblado el resto; y aísla con fixture fresca o rollback para que cada test empiece limpio. Son dos de los cinco criterios de la rúbrica; fallarlos hunde la entrega aunque los tests estén verdes.
Cierre de la guía: los ocho módulos
Cerraste el capstone; cerremos la guía. El recorrido completo, en una frase por módulo:
- De la unidad a la integración. Un unit test verde puede esconder una integración rota, porque el doble mentía; las piezas aisladas pasan, las juntas fallan.
- El doble que mintió. La divergencia es la tendencia natural de todo doble: el fake devuelve
Nonedonde el real lanza, y el bug pasa el unit test y explota en producción. - Contract testing: consumer y provider. El contrato es un spec de comportamiento consumer-driven, hecho batería parametrizada, que ambos lados cumplen; manda el consumer.
- Verificar el contrato desde los dos lados. El test del consumer y el del provider, la misma batería contra el fake y el real, y la caza de un cambio incompatible antes de desplegarlo.
- Integración de componentes reales juntos.
BookingService+SqliteBookingRepositoryde verdad, cruzando la costura; qué mantener real y qué doblar; el flujo que caza lo que la inspección no ve. - Fronteras reales: DB, archivos, HTTP. Probar en la frontera —una transacción, un archivo, una llamada a un
http.serverde la stdlib— rápido y determinista. - Datos y aislamiento en integración. El rollback y las fixtures de recursos reales para que cada prueba empiece limpia; la fragilidad del estado real compartido.
- Capstone. El proceso completo sobre Reservo: contrato consumer-driven + verificación desde ambos lados + integración de punta a punta aislada, evaluado por el método.
La idea que atraviesa los ocho, y el tagline del ecosistema: saber cuáles tests sirven. Un test sirve cuando prueba algo que puede fallar de verdad y lo prueba de forma que, si falla, te enteras temprano y con precisión. Un contrato sirve porque mantiene honestos a tus dobles; una integración sirve porque verifica la colaboración real. Sabes cuál escribir, cuándo, y por qué —que es exactamente lo que separa una suite que da confianza de una que da falsa confianza—.
A dónde seguir
Esta guía deja fronteras deliberadas, y cada una es el punto de partida de una guía hermana del ecosistema de Testing:
- Para probar una app web completa —un framework como FastAPI, rutas, el ciclo petición-respuesta, HTTP de punta a punta— sigue con
testing-backend-applications-guide. Aquí trabajaste con servicios en proceso y un repositorio SQLite; allá pruebas la aplicación entera. Es el siguiente paso natural: llevas la costura probada, ella te enseña a probar la app. - Si algo de stubs, spies, mocks o fakes te quedó flojo,
test-doubles-and-test-data-guidees la guía hermana donde se construyen los dobles que aquí asumimos y verificamos. Es el cimiento del que parte todo lo que hiciste con elFakeBookingRepository. - Para los fundamentos y el TDD —escribir el test primero, el ciclo rojo-verde-refactor, pytest desde cero— está
testing-fundamentals-and-tdd-guide, la base sobre la que se para esta guía. - Para llevar tus tests más allá de los ejemplos —generar casos automáticamente con property-based testing, invariantes,
Hypothesis— sigue conproperty-based-and-advanced-testing-guide. Donde aquí escribiste cláusulas a mano, allá aprendes a que la máquina invente los casos que romperían tu contrato. - Cuando un test se ponga rojo y no sepas por qué —leer un traceback, aislar la causa, distinguir un bug del código de un bug del test—
test-failure-diagnosis-guidees la guía hermana que enseña a diagnosticar el rojo que aquí aprendiste a provocar a propósito.
Con esto cierras contract e integration testing. Sabes construir un contrato que mantiene honestos a tus dobles, probar la colaboración real de tus componentes, aislar esas pruebas para que sean confiables, y usar el contrato como red de seguridad del deploy. Sobre todo, sabes decidir: qué doblar, qué probar real, qué merece un contrato y qué una integración —y por qué—. Esa es la habilidad que la guía prometía, y la que te llevas.
Recursos
- Documentación de pytest — Parametrizando fixtures y funciones de test — el mecanismo central del entregable 2: una batería, dos providers, sin duplicar código.
sqlite3— DB-API para SQLite (documentación de Python) — el recurso real que el contrato certifica y la integración cruza;connect(":memory:")da la base efímera y aislada de la entrega.- docs.pact.io — Contratos consumer-driven — la herramienta industrial que automatiza y pone en red lo que aquí armaste a mano; el destino cuando la costura sea entre servicios y no en proceso.
testing-backend-applications-guide— la guía hermana a la que sigue el camino: probar una aplicación web de verdad, con framework, rutas y HTTP de punta a punta, el otro lado de la frontera que esta guía deja marcada.