Módulo 3: Contract testing: el contrato consumer/provider
1. Presentación del módulo: del problema a la cura
Descripción
El módulo 2 terminó con un diagnóstico y sin una receta. Viste, con salida real de pytest, cómo el FakeBookingRepository divergía del SqliteBookingRepository de verdad: pedir una reserva que no existe hacía que el fake devolviera None mientras el real lanzaba una excepción. Y viste la parte que quita el sueño: ese desajuste pasaba el unit test —porque el unit test solo hablaba con el fake— y explotaba en producción —donde el que atiende es el real—. El doble mintió, el verde nos engañó, y el bug viajó tranquilo hasta el usuario. Este módulo es la cura.
La cura tiene un nombre viejo y una idea simple: un contrato. En vez de escribir por separado los tests del fake y los tests del real (y rezar para que coincidan), escribes un solo spec de comportamiento —qué debe hacer cualquier repositorio digno de ese nombre— y lo verificas contra todas las implementaciones a la vez. La misma batería de tests corre contra el FakeBookingRepository y contra el SqliteBookingRepository. Si ambos pasan, tienes una garantía que ningún unit test aislado te podía dar: el fake no está mintiendo sobre nada que el contrato cubra, porque el contrato lo obliga a comportarse igual que el real. Y si el fake diverge —como el del módulo 2—, la batería lo pinta de rojo antes de que el bug salga de tu máquina.
Conexión con el módulo: esta lección es el mapa de la cura, no la cura en detalle. Aquí vas a entender qué es un contrato, por qué una batería parametrizada cierra la brecha del módulo 2, y cómo se organizan las ocho lecciones que vienen. Las lecciones 2 y 3 asientan las ideas —qué es un contrato, quién lo define (el consumer)—; la 4 te da el mecanismo concreto en pytest (la fixture parametrizada); la 5 cobra la recompensa cazando la divergencia del módulo 2 en rojo; la 6 distingue dos formas de escribir una cláusula (estado contra interacción); y la 7 te muestra cómo la industria automatiza todo esto entre servicios de red (el concepto de Pact). El módulo 4 tomará el relevo para separar los dos lados del contrato —el test del consumer y el test del provider— con lupa; aquí el foco es el contrato como batería compartida.
Analogía: la norma del enchufe
Piensa en el enchufe de la pared. En tu país hay una norma: la forma de las clavijas, la separación entre ellas, el voltaje, la frecuencia. Esa norma es un contrato entre dos partes que no se conocen y nunca se hablan: el que fabrica el cargador (el consumer, el que usa la corriente) y el que instala el tomacorriente (el provider, el que entrega la corriente). Ninguno vio el diseño del otro. Y sin embargo tu cargador funciona en cualquier pared del país, porque los dos cumplen la misma norma. El fabricante del cargador no reza "ojalá esta pared entregue 120 voltios"; sabe que los entrega, porque la norma lo garantiza y hay un laboratorio que certifica que cada tomacorriente la cumple antes de venderlo.
Quita la norma y tienes el mundo del módulo 2. El fabricante del cargador supone cómo es la pared —clava su suposición en un adaptador de mentira que arma él mismo, en su taller— y prueba el cargador contra ese adaptador. Funciona de maravilla. El día del estreno lo enchufa a una pared real que resultó ser de otro voltaje, y el cargador se quema. El adaptador de taller nunca podía avisar del problema, porque era la suposición equivocada hecha objeto. Lo que faltaba era una norma escrita y un laboratorio que certificara que el adaptador de taller y la pared real cumplen la misma especificación. El contrato de esta guía es esa norma; la batería parametrizada es ese laboratorio. Certificas al FakeBookingRepository y al SqliteBookingRepository contra el mismo spec, y ya no rezas: sabes.
Qué es un contrato, en una frase
Antes de ver código, quédate con la definición que la lección 2 desmenuzará:
Un contrato es un spec de comportamiento —no de forma— que describe qué promete cualquier implementación de una interfaz, y que se verifica igual contra todas ellas.
Fíjate en las tres palabras cargadas. Comportamiento, no forma: no basta con que el fake y el real tengan los mismos métodos (save, get, find_by_room); tienen que actuar igual —guardar y leer devuelve la misma reserva, pedir una ausente lanza, guardar dos veces el mismo id actualiza en vez de duplicar—. Cualquier implementación: el contrato no es del fake ni del real; es de la idea "repositorio de reservas", y ambos deben rendirle cuentas. E igual contra todas: una sola batería, corrida N veces, una por implementación. Esa última palabra —igual— es la que hace el trabajo. Si el fake pasa una batería distinta de la del real, no probaste que coincidan; probaste dos cosas sueltas. La misma batería contra ambos es lo que convierte "espero que coincidan" en "coinciden o hay un rojo".
El contrato del BookingRepository
El repositorio de Reservo expone tres métodos, y su contrato son cuatro cláusulas de comportamiento. Las verás una y otra vez en el módulo, así que conócelas desde ya:
- Guardar y leer devuelve la misma reserva. Si haces
save(booking)y luegoget(booking.id), recuperas una reserva igual a la que guardaste —mismos campos, mismos tipos—. getde un id ausente lanza. Pedir una reserva que no existe levanta una excepción (KeyError), no devuelveNoneni una reserva vacía. (Esta es exactamente la cláusula que el fake del módulo 2 violaba.)- Guardar dos veces el mismo id actualiza, no duplica. Un segundo
savecon el mismoidreemplaza a la reserva anterior; no crea una segunda fila. find_by_roomdevuelve solo las reservas de esa sala. Filtra porroom_idy no arrastra reservas de otras salas.
Estas cuatro frases son el contrato. No dependen de cómo se implemente el repositorio —un dict en memoria, una tabla de SQLite, un archivo, un servicio remoto—; describen lo que el consumer necesita poder confiar. Y esa es la clave del módulo: son las necesidades de BookingService (el consumer) las que dictan las cláusulas. Volveremos a esto en la lección 3.
Primero, el arreglo que el módulo 1 dejó pendiente
Antes de poner el SqliteBookingRepository a rendir cuentas contra el contrato, hay que saldar una deuda del módulo 1. Allí, la integración con el repositorio real expuso un bug que dejamos en rojo a propósito: get leía la columna start —un TEXT— y devolvía el valor tal cual, como str, sin reconstruir el datetime; el fake, en cambio, devolvía el datetime intacto. Esa era la divergencia de tipos que el módulo 1 mostró y no arregló. Para poder contract-testear —para que las salidas verdes de este módulo digan la verdad y no sean un espejismo— lo arreglamos ahora, donde el bug siempre debió arreglarse: en el provider. get reconstruye el datetime al leer, con datetime.fromisoformat(row[3]):
# reservo/sqlite_repo.py — get() con el arreglo del datetime aplicado
from datetime import datetime
# ...
def get(self, booking_id):
row = self._conn.execute(
"SELECT id, room_id, member_id, start, end, status, price_cents "
"FROM bookings WHERE id = ?",
(booking_id,),
).fetchone()
if row is None:
raise KeyError(booking_id) # id ausente -> lanza
return Booking(
id=row[0], room_id=row[1], member_id=row[2],
start=datetime.fromisoformat(row[3]), # texto -> datetime de vuelta
end=datetime.fromisoformat(row[4]),
status=row[5], price_cents=row[6],
)
Con ese get, el SqliteBookingRepository ya es un provider fiel: reconstruye los tipos al leer y lanza KeyError en el id ausente. De aquí en adelante, el repositorio real de Reservo lleva este arreglo —salvo cuando un módulo lo revierta a propósito para volver a exhibir el bug, como hará el módulo 5 con el flujo completo—. Es lo que hace que las ocho verdes de abajo sean ciertas contra el repositorio que construiste, y no una salida optimista. (El módulo 5 usará ese rollback deliberado para mostrar algo que el contrato de este módulo no alcanza: que un contrato con un hueco podría no cazar este bug, y que la integración del flujo completo sí.)
Ejemplo trabajado: la cura de un vistazo
Veamos la cura funcionando —adelanto del módulo, no hace falta que lo escribas ahora—. La batería del contrato son cuatro tests, uno por cláusula. El truco está en la fixture parametrizada: en lugar de recibir un repositorio fijo, cada test recibe una fixture repo que pytest rellena dos veces —una con el fake, una con SQLite—. Así, las cuatro cláusulas corren contra ambas implementaciones sin duplicar una sola línea de test.
# 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 MISMA fixture entrega, en cada corrida, un fake y un SqliteBookingRepository.
@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"]
Cuatro tests, dos implementaciones: pytest ejecutará ocho casos.
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 ==============================
Lee los ids entre corchetes: [fake] y [sqlite]. Cada cláusula aparece dos veces, una por implementación, y las ocho pasan. Eso es una certificación: el fake y el real cumplen las cuatro cláusulas del contrato, así que —dentro de lo que el contrato cubre— el fake no miente. La suposición que en el módulo 2 nadie verificaba ("mi fake se comporta como el real") aquí es un hecho verificado, con ocho verdes que lo respaldan.
¿Y si el fake sí mintiera? Es la pregunta que da sentido a todo. Si en vez del FakeBookingRepository correcto metemos el BuggyFakeBookingRepository del módulo 2 —el que devuelve None en get de un id ausente—, la misma batería lo delata:
tests/..._divergence.py::test_get_of_a_missing_id_raises[buggy-fake] FAILED [ 37%]
tests/..._divergence.py::test_get_of_a_missing_id_raises[sqlite] PASSED [ 50%]
...
E Failed: DID NOT RAISE KeyError
========================= 1 failed, 7 passed in 0.04s ==========================
Ahí está la cura en acción, resumida: el mismo test, test_get_of_a_missing_id_raises, pasa para [sqlite] y falla para [buggy-fake] con un mensaje que no deja dudas —DID NOT RAISE KeyError—. El contrato encontró exactamente el bug del módulo 2, en tu máquina, antes de desplegar. La lección 5 construye este rojo paso a paso; por ahora quédate con la forma: una batería, dos providers, y el que diverge no puede esconderse.
El mapa del módulo: las ocho lecciones
Cada lección apoya en la anterior. El recorrido te lleva de "sé que el doble puede mentir" a "tengo un contrato que no lo deja mentir, y sé cómo lo hace la industria a escala".
| Lección | Tema | La idea en una frase |
|---|---|---|
| 1 | Del problema a la cura (esta) | El contrato: un spec de comportamiento compartido, verificado igual contra todas las implementaciones |
| 2 | Qué es un contrato | La interfaz es la forma; el contrato es el comportamiento — y por eso dos implementaciones con la misma forma pueden divergir |
| 3 | Contratos consumer-driven | El consumer (BookingService) define qué espera; el provider promete cumplirlo |
| 4 | La batería parametrizada | Una fixture con params=["fake", "sqlite"] corre cada cláusula contra ambas implementaciones |
| 5 | El contrato caza la divergencia del módulo 2 | El fake que devuelve None en vez de lanzar aparece en rojo; el real sigue verde |
| 6 | Estado contra interacción | Afirmar sobre el resultado (el repositorio) contra afirmar sobre la llamada (el gateway) |
| 7 | El concepto de Pact | La versión industrial y en red: pact file, broker, verificación del provider |
| 8 | Mini-proyecto | Escribe el contrato del repositorio y córrelo contra el fake y SQLite; caza el fake divergente |
Dónde termina este módulo (la frontera)
Conviene marcar dos límites desde el arranque, porque hay temas que parecen de aquí y son del módulo siguiente o de otra guía.
Los dos lados del contrato en detalle son el módulo 4. Aquí tratamos el contrato como una batería compartida: una suite que corre contra varias implementaciones. Pero un contrato tiene dos caras que conviene mirar por separado —el test del consumer ("yo, BookingService, mando esto y espero aquello") y el test del provider ("yo, el repositorio, dado esto devuelvo aquello")—, y con ellas se caza un cambio incompatible antes de desplegar. Ese desglose, y ese caso de cambio roto, son el módulo 4. En este módulo, contrato = una batería que ambos cumplen.
La integración con recursos reales a fondo es del módulo 5 en adelante. Usamos el SqliteBookingRepository real como una de las implementaciones que el contrato certifica, pero no entramos todavía en las mañas de integrar de verdad: transacciones, rollback, archivos temporales, un http.server de la stdlib, el aislamiento entre tests que tocan estado real. Eso es de los módulos 5, 6 y 7. Aquí SQLite es "la pieza real que el contrato debe cubrir", no "el recurso que aprendemos a manejar".
Pact no se instala. En la lección 7 explicamos el concepto de Pact —contratos consumer-driven entre servicios de red— como la versión industrial de lo que aquí construyes a mano. Pero es un concepto de referencia: no instalamos la herramienta ni dependemos de nada fuera de la stdlib. Reservo sigue siendo pytest, sqlite3 y http.server, todo lo que ya tienes.
Errores comunes
Pensar que el contrato es "más tests del fake y más tests del real". Qué pasa: alguien entiende "verificar ambos" como "escribo la suite del fake, copio y pego, la adapto al real". Por qué pasa: es el reflejo natural —dos implementaciones, dos suites—. Cómo detectarlo: si tienes dos archivos de test que deberían afirmar lo mismo pero por separado, nada garantiza que sigan diciendo lo mismo cuando uno cambie. Cómo corregirlo: el contrato es una batería que corre contra ambos. Copiar y pegar produce dos specs que derivan; parametrizar produce un solo spec, imposible de desincronizar. Esa unicidad es justo lo que da la garantía.
Creer que "misma interfaz" ya implica "mismo comportamiento". Qué pasa: alguien ve que el fake y el real tienen los métodos save, get, find_by_room con las mismas firmas y concluye que son intercambiables. Por qué pasa: Python no te obliga a más; si los nombres coinciden, el código corre. Cómo detectarlo: el bug del módulo 2 es la prueba —misma interfaz, comportamiento distinto (None contra excepción)—. Cómo corregirlo: la interfaz es condición necesaria pero no suficiente; el contrato prueba el comportamiento, que es lo que la interfaz no captura. La lección 2 vive de esta distinción.
Querer "arreglar" la divergencia haciendo que el fake imite el defecto del real. Qué pasa: para que la batería quede verde, alguien hace que el fake reproduzca alguna maña indeseable del real en vez de decidir cuál es el comportamiento correcto. Por qué pasa: parece que "alinear" el fake al real cierra el rojo. Cómo detectarlo: si tu contrato ahora exige un comportamiento que no querrías en producción, horneaste el bug en el spec. Cómo corregirlo: el contrato describe el comportamiento correcto (por ejemplo, "get ausente lanza"), y ambos providers deben cumplirlo. Si el real no lo cumple, se arregla el real; si el fake no lo cumple, se arregla el fake. El contrato es el árbitro, no el molde del defecto.
Ejercicios
Ejercicio 1 — Interfaz o contrato. Para cada afirmación sobre el BookingRepository, di si describe la interfaz (la forma) o el contrato (el comportamiento): (a) "get recibe un str y devuelve un Booking"; (b) "get de un id que no existe lanza KeyError"; (c) "save no devuelve nada"; (d) "guardar dos veces el mismo id deja una sola reserva".
Ver solución
- (a) Interfaz. Habla de la forma: qué tipo recibe
gety qué tipo devuelve. Es la firma del método. Dos implementaciones podrían cumplir esta firma y aun así comportarse distinto. - (b) Contrato. Habla del comportamiento en un caso concreto (id ausente): qué hace, no qué forma tiene. Es exactamente la cláusula 2, la que el fake del módulo 2 violaba.
- (c) Interfaz. Describe la firma de
save(no retorna valor). Es forma. - (d) Contrato. Describe qué ocurre al repetir un
savecon el mismo id (actualiza, no duplica): es la cláusula 3, comportamiento observable.
La regla que estás afinando: la interfaz dice cómo se llama y qué tipos mueve; el contrato dice cómo se comporta. El bug del módulo 2 vivía justo donde la interfaz calla y el contrato habla.
Ejercicio 2 — Por qué una batería y no dos. Un compañero propone: "escribamos test_fake_repo.py con los tests del fake y test_sqlite_repo.py con los del real; total, prueban lo mismo". Explica por qué una única batería parametrizada es superior a dos suites gemelas para el objetivo de este módulo.
Ver solución
Dos suites gemelas resuelven el problema hoy y lo reabren mañana. En el momento de escribirlas quizá afirmen lo mismo, pero son dos textos independientes: cuando alguien añada una cláusula al contrato, o cambie una aserción, tiene que acordarse de tocar las dos. En cuanto una cambie y la otra no, vuelves al mundo del módulo 2 —el fake y el real probados contra expectativas distintas, libres para divergir sin que nadie lo note—. El objetivo del contrato no es "probar el fake" ni "probar el real"; es garantizar que se comportan igual, y eso solo lo garantiza una fuente única de verdad.
Una batería parametrizada es esa fuente única. Escribes la cláusula una vez y pytest la corre contra las dos implementaciones. Es imposible que "se te olvide actualizar la del real", porque no hay dos: hay una, corrida dos veces. La unicidad del spec es la garantía; la duplicación la destruye.
Ejercicio 3 — Qué garantiza el verde (y qué no). La batería del ejemplo pasa en verde para [fake] y [sqlite]. Un compañero concluye: "listo, el fake y el real son idénticos". Corrige la afirmación con precisión: ¿qué garantiza exactamente el verde, y qué queda fuera?
Ver solución
El verde garantiza algo fuerte pero acotado: el fake y el real se comportan igual en todo lo que el contrato cubre —las cuatro cláusulas: guardar-y-leer, get-ausente-lanza, save-actualiza, find_by_room-filtra—. Sobre esos cuatro comportamientos, ya no hay que rezar: están verificados contra ambas implementaciones.
Lo que el verde no garantiza es que sean idénticos en todo. Solo cubre lo que escribiste como cláusula. Si existe un comportamiento que importa y no está en la batería —digamos, cómo se ordena find_by_room, o qué pasa con un price_cents negativo, o la concurrencia—, el contrato guarda silencio sobre él, y ahí el fake y el real todavía podrían divergir sin que ningún rojo lo delate. La garantía de un contrato es tan amplia como sus cláusulas: cubre lo que enuncia, y nada más. Por eso escribir el contrato es decidir, con cuidado, qué comportamientos son parte del acuerdo. Un contrato vacío pasa siempre y no protege de nada; un contrato bien pensado cubre justo las costuras que pueden hacer daño.
Resumen y siguiente paso
En esta lección diste el salto del problema a la cura. El módulo 2 te mostró la enfermedad —el doble que miente y engaña al unit test—; aquí conociste el remedio: el contrato, un spec de comportamiento compartido que se verifica igual contra todas las implementaciones. Con la norma del enchufe entendiste la idea —una especificación que dos partes que no se hablan cumplen por igual, certificada por un laboratorio—; con las cuatro cláusulas del BookingRepository viste de qué está hecho un contrato concreto; y con la batería parametrizada corriendo [fake] y [sqlite] viste la cura funcionando: ocho verdes que certifican que el fake no miente, y un rojo (DID NOT RAISE KeyError) que caza al fake que sí lo hace.
Antes de avanzar deberías poder: definir un contrato como un spec de comportamiento (no de forma), verificado igual contra todas las implementaciones; enunciar las cuatro cláusulas del contrato del repositorio; y explicar por qué una batería parametrizada única —no dos suites gemelas— es lo que garantiza que el fake y el real no diverjan.
Lo que sigue es afilar la primera de esas ideas hasta el filo. En la lección 2 vamos a separar con toda claridad la interfaz (la forma: nombres y firmas) del contrato (el comportamiento: qué prometen de verdad), porque en la grieta entre esas dos cosas es donde vivía el bug del módulo 2. Entender esa distinción es lo que te deja escribir contratos que cubren justo lo que importa.
Recursos
- Documentación de pytest — Parametrizando fixtures y funciones de test — la referencia oficial de la parametrización, el mecanismo con el que una sola batería corre contra el fake y contra SQLite; en particular, cómo
paramsen una fixture genera los ids[fake]y[sqlite]que viste en la salida. sqlite3— DB-API para SQLite (documentación de Python) — la referencia del módulo de la stdlib que hace de "implementación real" del repositorio en todo el contrato; cero dependencias externas.- docs.pact.io — Introducción a los contratos consumer-driven — la documentación oficial de Pact, la herramienta que industrializa la idea de esta guía para servicios de red; la trataremos como concepto en la lección 7 sin instalarla, y este es el punto de partida para leer más.
test-doubles-and-test-data-guide— la guía hermana donde construiste elFakeBookingRepository; útil para recordar que un fake es una implementación que tú escribes, y por tanto una suposición que un contrato puede —y debe— poner a prueba.