Módulo 3: Contract testing: el contrato consumer/provider
5. El contrato caza la divergencia del módulo 2
Descripción
Este es el pago de todo el módulo. En el módulo 2 viste una divergencia concreta y dolorosa: el FakeBookingRepository devolvía None al pedir una reserva que no existe, mientras el SqliteBookingRepository real lanzaba. El bug pasaba el unit test —que solo hablaba con el fake— y explotaba en producción —donde atiende el real—. Diste vueltas al problema sin una receta. Las lecciones 1 a 4 construyeron la receta: qué es un contrato, quién lo define, cómo se corre contra varias implementaciones. Ahora la aplicamos al caso exacto que la motivó y vemos, con salida real, cómo la batería del contrato caza esa divergencia en rojo antes de que salga de tu máquina.
El experimento es limpio: tomamos la misma batería de cuatro cláusulas y le cambiamos un provider. En vez del FakeBookingRepository correcto, ponemos el BuggyFakeBookingRepository —el del módulo 2, el que devuelve None en get de un id ausente— junto al SqliteBookingRepository real. Corremos. Y observamos algo preciso: siete de los ocho casos pasan, y uno falla —test_get_of_a_missing_id_raises[buggy-fake]— con un mensaje que no admite interpretación: DID NOT RAISE KeyError. El id entre corchetes señala al culpable con el dedo: no es el real ([sqlite] pasa esa cláusula), es el fake. El contrato convirtió una divergencia invisible —que en el módulo 2 solo se descubría en producción— en un rojo con nombre, línea y causa. Eso es la disciplina entera funcionando.
Conexión con el módulo: esta lección cierra el arco que abrió la lección 1 ("del problema a la cura") demostrando la cura sobre el problema original. Las lecciones 2 y 3 dieron el concepto; la 4 el mecanismo; aquí se cobra. Después de esto, el módulo se abre a los matices: la lección 6 distingue dos formas de escribir una cláusula (estado contra interacción) y la 7 muestra cómo la industria automatiza esto entre servicios (Pact). Pero el corazón del módulo late aquí: el fake que mintió, cazado por el contrato en rojo.
Analogía: la balanza patrón contra la balanza del puesto
En un mercado, cada puesto tiene su balanza. Un cliente sospecha que la balanza de un puesto está trucada —marca menos de lo que hay—, pero no puede probarlo mirándola: por fuera se ve idéntica a las demás. Entonces la oficina de pesas y medidas trae una pesa patrón certificada: un kilo exacto, el mismo para todos los puestos. La ponen en la balanza sospechosa y en la de un puesto honesto, y comparan. La honesta marca "1.000 kg"; la trucada marca "0.920 kg". La misma pesa, dos lecturas: la patrón no cambió, cambió el instrumento. Ahora la sospecha es un hecho medible, y el puesto trucado queda expuesto —no por su apariencia, sino por fallar la prueba estándar—.
La batería del contrato es esa pesa patrón. La misma prueba —"get de un id ausente debe lanzar"— se aplica a los dos providers. El real la pasa (marca lo correcto); el fake buggy la falla (marca None donde debía lanzar). La prueba no cambió entre uno y otro; cambió el instrumento, y por eso la divergencia queda expuesta. En el módulo 2, sin pesa patrón, la balanza trucada pasaba desapercibida hasta que un cliente se quejaba —el equivalente al bug en producción—. Con el contrato, la trucada se detecta en la inspección, antes de abrir el puesto. La lección de fondo es la misma: para saber si un instrumento miente, no lo mires; mídelo contra un patrón compartido.
Recordatorio: la divergencia del módulo 2
Pongamos los dos providers frente a frente, en la única cláusula donde difieren. El fake correcto:
# reservo/doubles.py — el fake que CUMPLE
class FakeBookingRepository:
def __init__(self):
self._store = {}
def get(self, booking_id):
return self._store[booking_id] # KeyError si no existe <-- LANZA
El fake buggy del módulo 2:
# reservo/doubles.py — el fake que MINTIO
class BuggyFakeBookingRepository:
def __init__(self):
self._store = {}
def get(self, booking_id):
return self._store.get(booking_id) # devuelve None si no existe <-- NO LANZA
La diferencia es una sola palabra: self._store[booking_id] (indexación, que lanza KeyError si falta la clave) contra self._store.get(booking_id) (el método .get del dict, que devuelve None si falta). Un cambio minúsculo, de una letra y un punto, con una consecuencia enorme: rompe la cláusula 2 del contrato. Y el SqliteBookingRepository real, recordemos, lanza KeyError explícitamente cuando la fila no existe:
# reservo/sqlite_repo.py — el real (extracto de get)
def get(self, booking_id):
row = self._conn.execute(..., (booking_id,)).fetchone()
if row is None:
raise KeyError(booking_id) # <-- LANZA, como manda el contrato
return Booking(...)
Así que el fake buggy y el real difieren exactamente en la cláusula 2. En el módulo 2, esa diferencia se escondía porque cada uno se probaba (o no) por su lado. Ahora los pondremos bajo la misma pesa patrón.
Ejemplo trabajado: la batería caza al fake que miente
Corremos la misma batería de cuatro cláusulas, pero esta vez con el BuggyFakeBookingRepository en lugar del correcto. Fíjate que lo único que cambia respecto a la lección 4 es la línea de la fixture que construye el provider "fake":
# 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)
# La MISMA bateria, ahora contra el fake DIVERGENTE del modulo 2 y el 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"]
Las cuatro cláusulas son idénticas a las de la lección 4 —el contrato no cambió, es un texto fijo—. Lo único distinto es que la fixture entrega el fake buggy. Corramos y leamos con lupa.
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 ==========================
Lee el resultado con calma, porque cada detalle importa:
- Siete pasan, uno falla. El fake buggy cumple tres de las cuatro cláusulas —guarda y lee bien, actualiza sin duplicar, filtra por sala—; solo viola la cláusula 2. El contrato no lo condena en bloque: señala exactamente dónde diverge. Esa precisión es oro para depurar.
- El único rojo es
[buggy-fake], no[sqlite]. El mismo test,test_get_of_a_missing_id_raises, pasa para[sqlite](percentil 50%) y falla para[buggy-fake](percentil 37%). El id entre corchetes es el dedo acusador: te dice que el que incumple es el fake, no el real. Si hubiera fallado[sqlite]en cambio, la lectura sería la opuesta —el real está roto—. El id convierte "algo falló" en "este provider falló esta cláusula". - El mensaje es inequívoco:
DID NOT RAISE KeyError.pytest.raises(KeyError)esperaba querepo.get("does-not-exist")lanzara; el fake buggy devolvióNoneen silencio, así que el bloque terminó sin excepción y pytest lo reporta como fallo con esas palabras exactas. No hay que adivinar la causa: el fake no lanzó cuando el contrato lo exigía.
Esto es lo que en el módulo 2 no teníamos. Allí, esta misma divergencia solo se manifestaba cuando BookingService.cancel recibía un None y estallaba con un AttributeError críptico, en producción, lejos de la causa. Aquí se manifiesta como un rojo nítido, en tu máquina, en la línea 34, con el provider culpable etiquetado. El contrato movió el descubrimiento del bug desde "una hora después del despliegue, en un log de producción" hasta "el segundo en que corriste la batería". Ese desplazamiento —del tarde y caro al pronto y barato— es la razón de ser de todo el módulo.
Cómo se cierra el rojo (y cómo NO)
Tienes el rojo. ¿Ahora qué? La respuesta correcta depende de cuál comportamiento es el correcto, y esa decisión la dicta el consumer (lección 3). BookingService.cancel necesita que get de un id ausente lance, para no seguir con un None y estallar más tarde. Así que el comportamiento correcto es "lanzar", el real ya lo cumple, y el que está mal es el fake. El arreglo es alinear el fake al contrato:
# El arreglo correcto: el fake cumple la clausula 2.
def get(self, booking_id):
return self._store[booking_id] # indexacion: lanza KeyError si falta
Se cambia .get(booking_id) por [booking_id], y la batería vuelve a los ocho verdes. La divergencia se cerró haciendo que el provider incorrecto cumpla el contrato correcto.
Ahora, el arreglo equivocado, que conviene nombrar para no caer en él: hacer que el real devuelva None "para que coincida con el fake" y el rojo desaparezca. Eso quita el síntoma y hornea el bug: dejarías al SqliteBookingRepository devolviendo None en un caso donde el consumer necesita una excepción, y cancel volvería a estallar en producción —ahora sin ningún test que avise, porque "alineaste" la batería al comportamiento roto—. La regla es firme: el contrato describe el comportamiento correcto (el que el consumer necesita), y se arregla al provider que no lo cumple; nunca se degrada el contrato para acallar un rojo. Un rojo no es un enemigo que silenciar; es la balanza patrón diciéndote cuál instrumento está trucado.
Por qué esto no se podía cazar con solo unit tests
Vale la pena cerrar el círculo con el módulo 1. ¿Por qué mil unit tests del fake no cazaban esto? Porque todos compartían la premisa del fake. Un unit test de cancel con el BuggyFakeBookingRepository que probara el caso feliz (cancelar una reserva que existe) pasaría en verde —la reserva existe, get la devuelve, todo fluye—. El caso del id ausente, si alguien lo probara contra el fake, "confirmaría" que devuelve None —porque eso es lo que el fake hace—, sin sospechar que el real hace otra cosa. El unit test no puede atrapar un bug que vive en la diferencia entre el fake y el real, porque solo mira uno de los dos.
El contrato rompe ese encierro con una idea que ya es tuya: no prueba un provider, prueba la cláusula contra todos. Al correr test_get_of_a_missing_id_raises contra el fake buggy y el real a la vez, obliga a que ambos coincidan con el comportamiento esperado —y el que no coincide se ilumina en rojo—. Es la única forma de ver una divergencia: mirar a los dos lados bajo la misma prueba. El unit test mira un lado; el contrato mira todos con la misma vara. Por eso el contrato caza lo que el unit test, por diseño, no puede.
Errores comunes
Silenciar el rojo degradando el contrato. Qué pasa: aparece [buggy-fake] FAILED y alguien "arregla" el test —lo borra, lo marca xfail, o cambia la cláusula para que acepte None—. Por qué pasa: un rojo molesta y la salida rápida es callarlo. Cómo detectarlo: si tu arreglo hace que la batería tolere el comportamiento que el consumer no quiere, degradaste el contrato. Cómo corregirlo: el rojo señala un provider que incumple una necesidad real; se arregla el provider, no el test. Degradar el contrato es apagar la alarma y dejar el incendio.
Arreglar el provider equivocado. Qué pasa: falla [buggy-fake] y alguien cambia el SqliteBookingRepository para que también devuelva None, "así los dos coinciden". Por qué pasa: se busca que la columna quede toda verde sin pensar cuál comportamiento es el correcto. Cómo detectarlo: si tu arreglo hace que el provider real deje de cumplir lo que el consumer necesita, arreglaste al que estaba bien. Cómo corregirlo: decide primero cuál es el comportamiento correcto según el consumer (aquí, "lanzar"); alinea al provider que se desvía de él (aquí, el fake). El real ya estaba bien; tocarlo introduce el bug que el contrato acababa de cazar.
Ignorar el id [...] y depurar a ciegas. Qué pasa: se ve "1 failed" y se empieza a revisar código al azar sin mirar qué caso falló. Por qué pasa: la prisa hace saltarse la línea del FAILED. Cómo detectarlo: si no sabes contra qué provider falló la cláusula, te falta el dato que la salida ya te dio. Cómo corregirlo: lee el id. [buggy-fake] te lleva directo al fake; [sqlite] te llevaría al real. El id es el mapa al culpable; empezar sin leerlo es depurar con los ojos vendados.
Ejercicios
Ejercicio 1 — Lee la columna. En la salida del ejemplo, test_get_of_a_missing_id_raises aparece dos veces: [buggy-fake] FAILED y [sqlite] PASSED. Un compañero concluye "el get de SQLite está roto". ¿Tiene razón? ¿Qué dice de verdad esa pareja de resultados?
Ver solución
No tiene razón; leyó la columna al revés. El caso que falló es [buggy-fake], no [sqlite]. [sqlite] PASSED dice que el SqliteBookingRepository cumple la cláusula 2: su get de un id ausente sí lanza KeyError. El que incumple es el fake buggy.
La pareja de resultados, leída bien, dice exactamente dónde está el problema: la misma cláusula pasa para un provider y falla para el otro, así que la divergencia está en el provider que falla ([buggy-fake]), medido contra el que pasa ([sqlite]), que sirve de referencia correcta. Esa es la potencia del id entre corchetes: no solo te dice que hubo un desacuerdo, te dice quién está del lado correcto (el que pasa) y quién del incorrecto (el que falla). Confundir cuál es cuál —como hizo el compañero— lleva a "arreglar" el provider sano. Leer la columna con cuidado lleva al culpable real.
Ejercicio 2 — Predice el rojo. Imagina un tercer provider, SloppyFakeBookingRepository, cuyo save guarda bien pero cuyo find_by_room devuelve todas las reservas, ignorando el room_id. Lo metes en la batería junto al fake correcto y a SQLite (params=["good-fake", "sloppy-fake", "sqlite"]). Sin correr nada, di cuántos casos habrá, cuáles fallarán y con qué id.
Ver solución
Habrá 12 casos: 4 cláusulas × 3 providers.
Fallará un solo caso: test_find_by_room_returns_only_that_rooms_bookings[sloppy-fake]. Ese test guarda una reserva en "focus" y otra en "studio", pide find_by_room("focus") y afirma que devuelve exactamente ["bk-1"]. El SloppyFakeBookingRepository, que ignora el room_id y devuelve todas, entregaría ["bk-1", "bk-2"], así que la aserción [b.id for b in found] == ["bk-1"] falla. Los otros tres providers-casos de esa cláusula ([good-fake], [sqlite]) pasan, y las otras tres cláusulas pasan para los tres providers.
El diagnóstico completo, antes de correr: 1 failed, 11 passed, y el rojo es ...returns_only_that_rooms_bookings[sloppy-fake]. Fíjate en el patrón: cada tipo de divergencia enciende la cláusula que la cubre, y el id señala al provider que la comete. Un contrato con las cláusulas correctas es un detector con un foco por cada comportamiento que te importa.
Ejercicio 3 — El bug que el contrato no cubría. El BuggyFakeBookingRepository tenía además otra maña que el contrato actual no caza: su find_by_room devuelve las reservas pero mutando una copia compartida, de modo que si el consumer modifica una reserva devuelta, se corrompe el almacén interno del fake. La batería de cuatro cláusulas pasa igual. Explica por qué no se caza y qué habría que hacer.
Ver solución
No se caza porque ninguna cláusula prueba ese comportamiento. Las cuatro cláusulas verifican: guardar-y-leer, get-ausente-lanza, save-actualiza, find_by_room-filtra-por-sala. Ninguna toca la cuestión de si las reservas devueltas comparten identidad con las almacenadas —si mutar una afecta a la otra—. Como el contrato guarda silencio sobre eso, un provider puede divergir ahí libremente y la batería seguirá verde. Es la misma lección de siempre: el contrato cubre lo que enuncia, y nada más; un comportamiento sin cláusula es un comportamiento sin protección.
Qué habría que hacer: si el consumer de verdad depende de que las reservas devueltas sean independientes del almacén (una necesidad real —por ejemplo, cancel modifica el status de la reserva que recibió—), esa necesidad debe volverse una cláusula. Por ejemplo:
# Clausula 5: mutar una reserva devuelta no altera lo almacenado.
def test_returned_bookings_are_independent_from_storage(repo):
repo.save(a_booking(status="confirmed"))
got = repo.get("bk-1")
got.status = "tampered" # el consumer muta su copia
assert repo.get("bk-1").status == "confirmed" # el almacen no cambio
Al añadirla, el provider que comparte identidad se pondría rojo en [...]_independent_from_storage[buggy-fake], mientras el real (que reconstruye un Booking nuevo desde la fila en cada get) pasaría. La moraleja para diseñar contratos: cada vez que descubres una divergencia que el contrato no cazó, la lección no es "el contrato falló", sino "faltaba una cláusula" —y la añades, dirigida por la necesidad real del consumer—.
Resumen y siguiente paso
En esta lección cobraste la promesa del módulo: viste el contrato cazar la divergencia del módulo 2 en rojo. Metiste el BuggyFakeBookingRepository —el que devuelve None en vez de lanzar— en la misma batería que el real, corriste, y obtuviste un diagnóstico quirúrgico: siete verdes, un rojo, test_get_of_a_missing_id_raises[buggy-fake] — DID NOT RAISE KeyError. El id entre corchetes señaló al culpable (el fake, no el real); el mensaje nombró la causa (no lanzó cuando debía); y todo ocurrió en tu máquina, en la línea 34, no en un log de producción una hora tarde. Con la balanza patrón fijaste la imagen: para saber si un instrumento miente, mídelo contra un estándar compartido. Y aprendiste a cerrar el rojo bien —alinear al provider que incumple con el comportamiento que el consumer necesita— y a no cerrarlo mal —degradar el contrato o arreglar el provider sano—.
Antes de avanzar deberías poder: leer la columna [fake]/[sqlite] para saber quién incumple y quién es la referencia correcta; explicar por qué ningún volumen de unit tests cazaba esta divergencia y por qué el contrato sí; y decidir el arreglo correcto de un rojo según el comportamiento que el consumer necesita, sin degradar el contrato ni tocar al provider sano.
Hasta aquí, todas nuestras cláusulas afirmaban sobre el resultado —qué devuelve get, qué contiene find_by_room—. Pero no todos los contratos se escriben así. En la lección 6 verás una segunda forma: los contratos de interacción, que afirman sobre la llamada —a quién se llamó, con qué argumentos, cuántas veces— en vez de sobre el resultado. El repositorio pide contratos de estado; el PaymentGateway suele pedir de interacción. Saber cuál usar para cada colaborador es el siguiente afinamiento.
Recursos
- Documentación de pytest —
pytest.raisesy afirmaciones sobre excepciones — la mecánica exacta de la cláusula que caza la divergencia; en particular, qué significa el mensajeDID NOT RAISEcuando el bloque no lanza la excepción esperada. - Documentación de pytest — Cómo entender los reportes de fallo — para leer el bloque de
FAILURES, el id[buggy-fake]y elshort test summary infocomo hiciste en el ejemplo trabajado. sqlite3— DB-API para SQLite (documentación de Python) — la referencia del provider real cuyogetlanzaKeyErroren el id ausente, y que sirve de referencia correcta contra la que se mide al fake divergente.test-doubles-and-test-data-guide— la guía hermana donde nació elFakeBookingRepository; útil para recordar que un fake es código que tú escribes, y por eso puede desviarse del contrato con un simple.get()en vez de[].