Módulo 3: Contract testing: el contrato consumer/provider
3. Contratos consumer-driven
Descripción
Ya sabes qué es un contrato: un spec de comportamiento, no de forma. Queda la pregunta que decide cómo se escribe: ¿quién manda en el contrato? Cuando dos componentes se hablan, uno usa al otro. En Reservo, BookingService usa el BookingRepository; llama a save, a get, a find_by_room y confía en que se comporten de cierta manera. Al que usa lo llamamos consumer; al que provee el servicio, provider. La respuesta de esta lección —y el corazón de toda la disciplina— es que manda el consumer. Las cláusulas del contrato no las inventa el que implementa el repositorio; las dictan las necesidades reales del que lo usa. Por eso se llaman contratos consumer-driven: dirigidos por el consumer.
La idea suena al revés hasta que la ves. Lo intuitivo sería que el provider —el SqliteBookingRepository, que sabe de tablas y de SQL— defina qué promete, y que el consumer se adapte. Pero eso lleva a contratos inflados de detalles que a nadie le importan (cómo se llama la columna, qué índice hay) y flojos justo donde el consumer sufre (qué pasa cuando la reserva no existe). El giro consumer-driven es: el provider no promete "todo lo que sé hacer"; promete exactamente lo que sus consumers necesitan, ni más ni menos. BookingService.cancel llama a repo.get(id) y, si la reserva no existe, necesita que eso lance para poder reaccionar; esa necesidad concreta es la cláusula 2 del contrato. El contrato es el retrato de las necesidades del consumer, y el provider firma cumplirlo.
Conexión con el módulo: esta lección responde el quién del contrato, después de que la 2 respondiera el qué. Y prepara el terreno para lo que viene: si el consumer define las cláusulas, entonces la batería parametrizada (lección 4) es la forma de exigirle a cada provider que las cumpla, y cazar la divergencia del módulo 2 (lección 5) es descubrir a un provider —el fake buggy— que no cumple una necesidad real del consumer. Aquí vas a ver, con salida real, que las cláusulas del contrato del repositorio no salieron de la nada: cada una responde a algo que BookingService de verdad hace.
Analogía: el cliente que especifica el pedido
Piensa en una carpintería que fabrica mesas por encargo. Hay dos maneras de acordar qué mesa se entrega. En la primera, el carpintero decide: "hago mesas de roble, de 1.80 por 0.90, con estas patas torneadas; tómalo o déjalo". El cliente, que necesitaba una mesa de 1.20 para un cuarto pequeño, se adapta como puede o se va. El provider mandó, y el resultado sirve a medias. En la segunda, el cliente especifica: "necesito 1.20 por 0.80, que quepan seis sillas, resistente a un vaso de agua derramado, y que pase por una puerta de 0.75". Esas frases —las necesidades reales de quien va a usar la mesa— se vuelven el contrato. El carpintero es libre de elegir la madera, el tipo de junta, el acabado; pero se compromete a cumplir cada requisito del cliente, y hay una prueba para cada uno (¿caben seis sillas?, ¿pasa por la puerta?).
El segundo modo es consumer-driven. El cliente (consumer) no le dice al carpintero cómo construir —esa es libertad del provider—; le dice qué necesita observar en el resultado. El carpintero (provider) promete cumplir esas necesidades, y solo esas: no se le exige que la mesa flote ni que resista un incendio, porque el cliente no lo pidió. Así son los contratos de esta guía. BookingService no le dice al repositorio cómo guardar (¿dict?, ¿tabla?, ¿archivo?); le dice qué necesita poder confiar —que al guardar y leer recupere la misma reserva, que pedir una ausente lance—. Y el repositorio, sea el fake o el real, promete cumplir esas cláusulas. El contrato es la lista de requisitos del cliente, no el catálogo del carpintero.
De la necesidad del consumer a la cláusula del contrato
Hagamos el ejercicio explícito, porque es la esencia de "consumer-driven": tomemos lo que BookingService de verdad hace y veamos cómo cada uso se convierte en una cláusula. Mira el consumer:
# reservo/services.py — el CONSUMER (extracto)
class BookingService:
def book(self, room, member, start, end):
# ...valida, cobra...
booking = Booking(id=f"bk-{room.id}-{start.isoformat()}", ...)
self._repo.save(booking) # (A) necesita GUARDAR
# ...
return booking
def cancel(self, booking_id):
booking = self._repo.get(booking_id) # (B) necesita LEER, y que falle si no existe
refund = refund_cents(booking, booking.price_cents, self._clock.now())
booking.status = "cancelled"
self._repo.save(booking) # (C) necesita ACTUALIZAR sin duplicar
# ...
return refund
Cada línea que toca el repositorio es una necesidad, y cada necesidad reclama una cláusula:
- (A)
bookguarda y espera poder recuperar la reserva después. Sibookguarda una reserva perogetno la devuelve igual,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.cancel("bk-fantasma")no puede seguir como si nada: sigetdevolvieraNone, la línea siguiente (refund_cents(booking, ...)) estallaría con unAttributeErrorcríptico, o peor, calcularía basura. El consumer necesita quegetlance para poder reaccionar limpio. → Cláusula 2: get de un id ausente lanza. - (C)
cancelguarda de nuevo la misma reserva (ahora cancelada). Es el mismoidque ya existía; el consumer necesita que esto actualice el estado, no que cree una segunda reserva fantasma para la misma sala y horario. → Cláusula 3: save dos veces del mismo id actualiza, no duplica.
Fíjate en lo que no aparece. El consumer no necesita saber si el repositorio usa un dict o una tabla, ni cómo se llama la columna del precio, ni si hay un índice. Nada de eso entra al contrato, porque el consumer no depende de ello. El contrato es el retrato exacto de lo que BookingService toca y en lo que confía —el borde entre los dos componentes visto desde el lado del que usa—.
Ejemplo trabajado: el consumer corre contra cualquier provider
La prueba de que un contrato es consumer-driven es que el consumer mismo funciona, sin cambiar una línea, contra cualquier provider que cumpla el contrato. Escribamos tests del BookingService completo —cobrando, guardando, cancelando— y corrámoslos contra el fake y contra SQLite, parametrizando el repositorio. Si el consumer se comporta igual con ambos, es que ambos cumplen lo que el consumer necesita.
# tests/test_consumer_relies_on_contract.py
import sqlite3
from datetime import datetime
import pytest
from reservo.calendar import Calendar
from reservo.doubles import (FakeBookingRepository, 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) # Focus 3 h
NOW = datetime(2026, 3, 7, 9) # 72 h antes -> reembolso total
@pytest.fixture(params=["fake", "sqlite"])
def repo(request):
if request.param == "fake":
return FakeBookingRepository()
return SqliteBookingRepository(sqlite3.connect(":memory:"))
def make_service(repo):
return BookingService(Calendar(), FixedClock(NOW),
StubPaymentGateway(ok=True), SpyEmailSender(), repo)
# El consumer confia en la clausula 1 (guardar-y-leer): book -> cancel.
def test_consumer_can_book_then_cancel_against_any_provider(repo):
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
refund = service.cancel(booking.id)
assert refund == 6000 # 72 h antes -> total
# El consumer confia en la clausula 2 (get ausente lanza): cancel de un fantasma.
def test_consumer_cancel_of_a_missing_booking_raises(repo):
service = make_service(repo)
with pytest.raises(KeyError):
service.cancel("bk-does-not-exist")
Léelo con la lente de la lección: test_consumer_can_book_then_cancel_against_any_provider ejercita las cláusulas 1 y 3 desde dentro del consumer —book guarda, cancel lee esa misma reserva y la vuelve a guardar cancelada—; test_consumer_cancel_of_a_missing_booking_raises ejercita la cláusula 2 —cancel de un id inexistente, que debe propagar el error del get—. Y todo corre contra el fake y contra SQLite gracias a la fixture parametrizada.
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_consumer_relies_on_contract.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 4 items
tests/test_consumer_relies_on_contract.py::test_consumer_can_book_then_cancel_against_any_provider[fake] PASSED [ 25%]
tests/test_consumer_relies_on_contract.py::test_consumer_can_book_then_cancel_against_any_provider[sqlite] PASSED [ 50%]
tests/test_consumer_relies_on_contract.py::test_consumer_cancel_of_a_missing_booking_raises[fake] PASSED [ 75%]
tests/test_consumer_relies_on_contract.py::test_consumer_cancel_of_a_missing_booking_raises[sqlite] PASSED [100%]
============================== 4 passed in 0.01s ==============================
Cuatro verdes, dos por cada provider. El consumer se comporta idéntico con el fake y con SQLite: reserva, cancela con reembolso de 6000 centavos, y propaga el error al cancelar un fantasma. Esa igualdad no es casualidad ni suerte: es la señal de que ambos providers cumplen las cláusulas que el consumer necesita. Y fíjate en el detalle clave para la lección 5: el segundo test, ..._of_a_missing_booking_raises, pasa para ambos porque los dos providers lanzan en get de un id ausente. Si aquí metiéramos el fake buggy del módulo 2 —el que devuelve None—, el cancel seguiría a la línea refund_cents(None, ...) y estallaría con otro error, no con el KeyError esperado: el consumer rompería con ese provider. El contrato es consumer-driven porque la cláusula 2 existe precisamente para que el consumer no rompa; el fake que la viola es un provider que no sirve al consumer, y eso es lo que la batería delata.
Quién es consumer y quién provider (y por qué importa)
Vale la pena fijar los roles, porque son relativos a cada costura, no absolutos. En la costura repositorio, BookingService es el consumer (usa) y el BookingRepository es el provider (provee). En la costura de pagos, BookingService es de nuevo consumer y el PaymentGateway es el provider. Un mismo componente puede ser consumer de una costura y provider de otra: BookingService es consumer del repositorio, pero sería provider si una capa web por encima lo usara. El rol lo define la dirección del uso: el que llama es el consumer; el que responde es el provider.
¿Por qué importa quién es quién? Porque decide de quién salen las cláusulas. Un contrato consumer-driven se escribe mirando lo que el consumer hace y necesita, no lo que el provider puede ofrecer. Esto tiene una consecuencia práctica muy concreta: si un provider quiere quitar o cambiar un comportamiento, el contrato le dice si algún consumer depende de él. Si ninguna cláusula cubre ese comportamiento, es libre de cambiarlo; si una cláusula lo cubre, cambiarlo rompería a un consumer, y la batería se pondrá roja para avisarlo antes de desplegar. Esa es la superpotencia del enfoque, y el módulo 4 la exprime: el contrato consumer-driven convierte "¿alguien usa esto?" de una pregunta angustiosa a una respuesta que da un test.
Hay una segunda consecuencia, más sutil: el contrato consumer-driven mantiene el spec pequeño y honesto. Como solo entran las necesidades reales de consumers reales, no acumula cláusulas sobre comportamientos que nadie usa. Un contrato definido por el provider tiende a crecer ("prometamos también esto, por si acaso") y a volverse una carga que frena todo cambio. Uno definido por el consumer es exactamente tan grande como el uso real, ni más. Es la diferencia entre una lista de requisitos del cliente y un catálogo entero: la primera te dice qué probar; el segundo te ahoga en promesas que nadie reclamó.
Errores comunes
Dejar que el provider dicte el contrato. Qué pasa: el equipo del SqliteBookingRepository escribe el contrato desde lo que su implementación hace hoy —"prometemos que find_by_room devuelve en el orden del rowid", "prometemos que los ids son UUID"—. Por qué pasa: es lo que tienen a la mano; describen su código. Cómo detectarlo: si una cláusula menciona un detalle que ningún consumer usa (un orden que a nadie le importa, un formato interno de id), el contrato lo dictó el provider. Cómo corregirlo: pregúntate "¿qué consumer se rompería si esto no se cumpliera?". Si la respuesta es "ninguno", la cláusula sobra. El contrato son las necesidades del consumer, no el inventario del provider.
Meter en el contrato detalles de implementación del provider. Qué pasa: una cláusula afirma sobre cómo guarda el provider —"tras save, existe una fila en la tabla bookings"— en vez de sobre qué observa el consumer. Por qué pasa: es tentador verificar lo que se ve fácil desde adentro del provider. Cómo detectarlo: si la cláusula solo tiene sentido para una implementación (la de SQLite tiene tabla; el fake no), no es un contrato compartido —el fake no podría cumplirla ni aunque quisiera—. Cómo corregirlo: escribe la cláusula en términos que cualquier provider pueda cumplir, usando solo la interfaz pública (get, find_by_room). "Tras save(b), get(b.id) devuelve b" es cumplible por el dict y por la tabla; "existe una fila" no.
Confundir el rol en una costura con una etiqueta fija del componente. Qué pasa: alguien dice "BookingService es el consumer" a secas y se lía cuando aparece una capa que usa a BookingService. Por qué pasa: se toma el rol como propiedad del objeto, no de la relación. Cómo detectarlo: si no puedes decir "consumer de qué costura", te falta la mitad de la frase. Cómo corregirlo: los roles son por costura. BookingService es consumer del repositorio y del gateway, y sería provider de una capa superior. Nombra siempre la costura; el rol vive en la relación, no en el componente.
Ejercicios
Ejercicio 1 — De la necesidad a la cláusula. El método book de BookingService, tras validar y cobrar, hace self._repo.save(booking) y luego devuelve la reserva. Más tarde, una pantalla llama repo.find_by_room("focus") para listar las reservas de esa sala. Escribe la necesidad del consumer detrás de find_by_room y la cláusula del contrato que reclama.
Ver solución
La necesidad del consumer: la pantalla que lista una sala necesita que find_by_room("focus") devuelva todas y solo las reservas de esa sala —las que book guardó para "focus"—, sin arrastrar las de otras salas ni omitir ninguna. Si find_by_room devolviera reservas de "studio" mezcladas, la pantalla mostraría reservas ajenas; si omitiera alguna de "focus", ocultaría reservas reales.
La cláusula que reclama es la 4: find_by_room devuelve solo las reservas de esa sala. Su test guarda una reserva en "focus" y otra en "studio", pide find_by_room("focus") y afirma que devuelve exactamente ["bk-1"] —la de focus, no la de studio—. La necesidad concreta del consumer (listar bien una sala) se volvió una cláusula concreta del contrato. Ese es el flujo consumer-driven: cada cosa que el consumer hace con el provider se convierte en algo que el provider promete cumplir.
Ejercicio 2 — Una promesa que sobra. El equipo del SqliteBookingRepository propone añadir al contrato: "cláusula: save asigna a cada reserva un rowid interno incremental, accesible por repo.last_rowid()". Ningún método de BookingService llama a last_rowid(). ¿Debe entrar esta cláusula al contrato consumer-driven? Justifica.
Ver solución
No debe entrar. Un contrato consumer-driven solo incluye lo que algún consumer real necesita, y ningún consumer —BookingService ni la pantalla— llama a last_rowid() ni depende de ningún rowid. La cláusula la dicta el provider (habla de un detalle interno de la implementación SQLite), no una necesidad del consumer. Es justo el tipo de promesa que infla el contrato sin protegerlo de nada útil.
Además, hay un problema práctico que lo confirma: el FakeBookingRepository (un dict en memoria) no tiene rowid ni last_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: debe ser cumplible por cualquier provider, expresado solo en términos de la interfaz pública que el consumer usa. La cláusula se queda fuera. Si algún día un consumer de verdad necesitara un identificador incremental, entonces se añadiría —dirigida por esa necesidad, no por lo que el provider tiene a la mano—.
Ejercicio 3 — Rota el contrato desde el provider. Imagina que el equipo de infraestructura cambia el SqliteBookingRepository para que get de un id ausente devuelva None "por consistencia con otra librería" —sin saber que BookingService.cancel depende de que lance—. Explica: ¿qué pasaría en producción, y cómo el enfoque consumer-driven lo habría frenado antes?
Ver solución
En producción se rompería cancel. BookingService.cancel hace booking = self._repo.get(booking_id) y, en la línea siguiente, refund_cents(booking, booking.price_cents, ...). Si get devuelve None en vez de lanzar, booking es None y booking.price_cents estalla con AttributeError: 'NoneType' object has no attribute 'price_cents' —un error críptico, lejos de la causa, en tiempo de producción—. Peor aún: un cambio pensado como "cosmético" en el provider rompió un consumer que el equipo de infraestructura ni siquiera sabía que existía.
El enfoque consumer-driven lo habría frenado porque la necesidad de cancel —"get ausente debe lanzar"— ya está capturada como la cláusula 2 del contrato. En cuanto el provider cambiara get para devolver None, la batería del contrato correría test_get_of_a_missing_id_raises[sqlite] y se pondría roja con DID NOT RAISE KeyError, en la máquina del que hizo el cambio, antes de cualquier despliegue. El contrato consumer-driven convierte "¿alguien depende de que esto lance?" —una pregunta que el equipo de infraestructura no sabía hacerse— en un test que responde solo. Esa es la protección: las necesidades del consumer, escritas como cláusulas, vigilan cada cambio del provider. Es exactamente el caso que el módulo 4 desarrolla en detalle.
Resumen y siguiente paso
En esta lección respondiste el quién del contrato: manda el consumer. Con el cliente que especifica la mesa viste que las cláusulas salen de las necesidades reales del que usa el componente, no del catálogo del que lo implementa. Recorriste BookingService línea por línea y viste cómo cada uso del repositorio —guardar en book, leer en cancel, actualizar el estado cancelado— se convierte en una cláusula concreta del contrato. Y lo comprobaste con salida real: el consumer corre idéntico contra el fake y contra SQLite, cuatro verdes, porque ambos providers cumplen lo que el consumer necesita. También fijaste que los roles consumer/provider son por costura, no etiquetas fijas, y que un contrato consumer-driven se mantiene pequeño y honesto —tan grande como el uso real—.
Antes de avanzar deberías poder: explicar por qué manda el consumer y no el provider; traducir un uso concreto del repositorio en BookingService a la cláusula que reclama; y descartar del contrato las promesas que solo el provider quiere (detalles de implementación, comportamientos que nadie usa).
Ya tienes el qué (comportamiento) y el quién (el consumer). Falta el cómo: el mecanismo concreto para exigir un mismo contrato a varias implementaciones a la vez. En la lección 4 desarmamos la fixture parametrizada de pytest —la línea @pytest.fixture(params=["fake", "sqlite"]) que has visto pasar— y entiendes exactamente cómo hace que cada cláusula corra contra el fake y contra SQLite, y por qué esa mecánica es lo que garantiza que el fake no pueda mentir.
Recursos
- docs.pact.io — El enfoque consumer-driven, explicado — la documentación oficial de Pact, cuya premisa central es exactamente la de esta lección: el consumer define el contrato y el provider se verifica contra él; la referencia industrial de la idea que aquí construyes a mano.
- Martin Fowler — Consumer-Driven Contracts — el artículo que dio nombre a la técnica; útil para ver el patrón consumer/provider más allá de Reservo, en la comunicación entre servicios.
- Documentación de pytest — Parametrizando fixtures — el mecanismo con el que el mismo test del consumer corre contra el fake y contra SQLite; lo desarmamos a fondo en la lección 4.
test-doubles-and-test-data-guide— la guía hermana dondeBookingServicey sus colaboradores se presentaron; útil para recordar la costura consumer/provider antes de formalizarla como contrato.