Módulo 4: Verificar el contrato desde ambos lados

1. Presentación del módulo: el contrato tiene dos lados

Descripción

En el módulo 3 construiste un contrato y lo miraste como una sola cosa: una batería de tests parametrizada que corre contra el FakeBookingRepository y el SqliteBookingRepository real, y devuelve un veredicto. Una batería, dos providers, verde o rojo. Fue suficiente para entender qué es un contrato y por qué cierra la brecha del módulo 1. Pero un contrato, como cualquier acuerdo entre dos partes, no se vive desde un solo lugar: se vive desde dos sillas, y cada silla ve una mitad del acuerdo.

De un lado está BookingService, el consumer: el que usa al colaborador. Su pregunta es la de quien depende de otro: "yo mando una operación —guardar esta reserva, pedir aquella— y espero recibir un resultado; ¿me estoy apoyando solo en lo que el contrato promete, o estoy suponiendo de más?". Del otro lado está SqliteBookingRepository, el provider: el que implementa al colaborador. Su pregunta es la de quien cumple: "dado que me piden guardar y luego leer, ¿devuelvo la reserva que el contrato promete, con los tipos que promete, y lanzo cuando el contrato dice que lance?". La misma batería, dos preguntas. Este módulo abre el contrato por la mitad y te enseña a escribir, leer y correr cada lado por separado.

Conexión con el módulo: esta lección es el mapa del módulo 4. Aquí no vas a escribir todavía cada test; vas a entender por qué separar los dos lados no es una sutileza académica, sino lo que convierte el contrato en una herramienta de trabajo. Separados, puedes verificar el consumer sin una base de datos y el provider sin el servicio; puedes correr la misma suite contra el fake y el real para garantizar que ninguno miente; y —el pago que da nombre a toda la guía— puedes cazar un cambio incompatible antes de desplegarlo. La frontera con el módulo 5 es clara: aquí seguimos con el contrato del repositorio aislado, visto desde sus dos lados; conectar los componentes reales juntosBookingService y SqliteBookingRepository cruzando la costura de verdad— es el módulo 5.

Analogía: el enchufe y el tomacorriente

Piensa en el sistema eléctrico de tu casa. Hay un acuerdo invisible entre dos piezas que nunca se diseñaron juntas: el enchufe de tu lámpara y el tomacorriente de la pared. El acuerdo es el estándar del país: dos clavijas planas, tantos milímetros de separación, 120 voltios, tal frecuencia. Ese estándar es el contrato. Y tiene dos lados, verificados por dos fabricantes que no se conocen.

El fabricante de la lámpara —el consumer, el que usa la corriente— prueba su producto así: "si me dan 120 voltios por dos clavijas separadas esta distancia, mi lámpara enciende". No prueba contra tu pared específica; prueba contra el estándar. Y algo crucial: si su lámpara solo funciona con un detalle que el estándar no garantiza —digamos, que la clavija de tierra siempre esté a la izquierda—, su producto es frágil, porque un tomacorriente perfectamente legal podría tenerla a la derecha. El fabricante del tomacorriente —el provider, el que entrega la corriente— prueba lo suyo del otro lado: "yo entrego 120 voltios por dos clavijas a esta distancia, cumpliendo el estándar". No necesita la lámpara de nadie para verificarlo; le basta un medidor y el estándar.

Los dos lados verifican el mismo contrato, desde sillas opuestas, sin coordinarse. Y cuando funciona, cualquier lámpara del país enciende en cualquier pared del país. Cuando el provider rompe el contrato —un tomacorriente que entrega 240 voltios "para simplificar"—, no hay que esperar a que alguien queme su lámpara para enterarse: un medidor contra el estándar lo caza al instante. Eso es exactamente lo que hace este módulo: escribir el test del consumer (la lámpara contra el estándar), el test del provider (el tomacorriente contra el estándar), y usar el estándar como el medidor que caza al provider que rompe el trato antes de que queme una lámpara en producción.

Ejemplo trabajado: los dos lados, de un vistazo

Antes de entrar al detalle de cada lado —que son las lecciones 2 y 3—, veamos el módulo entero condensado en dos corridas. Es el mismo contrato del repositorio del módulo 3, con sus cuatro cláusulas: guardar-y-leer devuelve la misma reserva; get de un id ausente lanza; guardar dos veces el mismo id actualiza sin duplicar; y find_by_room devuelve solo las reservas de esa sala. La batería está parametrizada con una fixture de dos valores, fake y sqlite, así que cada cláusula corre dos veces —una por provider—.

# tests/test_repository_contract.py — la batería de contrato compartida
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)


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)


# una fixture, dos providers: cada test corre dos veces, [fake] y [sqlite]
@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):
    repo.save(a_booking())
    got = repo.get("bk-1")
    assert got.id == "bk-1"
    assert got.room_id == "focus"
    assert got.start == START            # datetime, no str
    assert got.price_cents == 6000
    assert got.status == "confirmed"


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"))
    got = repo.get("bk-1")
    assert got.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"))
    ids = {b.id for b in repo.find_by_room("focus")}
    assert ids == {"bk-1"}

Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1), la batería corre las cuatro cláusulas contra los dos providers —ocho tests en total—:

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.01s ===============================

Ocho verdes. Fíjate en los ids entre corchetes: cada cláusula aparece dos veces, [fake] y [sqlite], y las dos pasan. Eso es el contrato honrado por ambos lados a la vez. El provider real —SQLite, que serializa el datetime a texto y lo reconstruye de vuelta— devuelve exactamente lo que el fake devuelve, porque los dos cumplen la misma cláusula test_save_then_get_returns_the_same_booking. (Ese got.start == START que en el módulo 1 fallaba contra SQLite ahora pasa: el arreglo del datetime —convertir de vuelta con fromisoformat— ya está aplicado, y el contrato es precisamente quien garantiza que siga aplicado. De eso se trata.)

Ahora el otro momento del módulo. Supón que el equipo que mantiene el SqliteBookingRepository decide "simplificar" get: en vez de lanzar cuando el id no existe, que devuelva None. Un cambio de una línea, de apariencia inofensiva. Corremos exactamente la misma batería, con el provider ya cambiado:

python3 -m pytest tests/test_repository_contract_after_change.py -v
tests/..._after_change.py::test_save_then_get_returns_the_same_booking[fake] PASSED [ 12%]
tests/..._after_change.py::test_save_then_get_returns_the_same_booking[sqlite] PASSED [ 25%]
tests/..._after_change.py::test_get_of_a_missing_id_raises[fake] PASSED [ 37%]
tests/..._after_change.py::test_get_of_a_missing_id_raises[sqlite] FAILED [ 50%]
tests/..._after_change.py::test_saving_the_same_id_twice_updates_not_duplicates[fake] PASSED [ 62%]
tests/..._after_change.py::test_saving_the_same_id_twice_updates_not_duplicates[sqlite] PASSED [ 75%]
tests/..._after_change.py::test_find_by_room_returns_only_that_rooms_bookings[fake] PASSED [ 87%]
tests/..._after_change.py::test_find_by_room_returns_only_that_rooms_bookings[sqlite] PASSED [100%]

=================================== FAILURES ===================================
___________________ test_get_of_a_missing_id_raises[sqlite] ____________________
...
>       with pytest.raises(KeyError):
E       Failed: DID NOT RAISE KeyError
========================= 1 failed, 7 passed in 0.02s ==========================

Ahí está el módulo entero en dos corridas. La primera: el contrato verde por ambos lados, [fake] y [sqlite]. La segunda: un cambio de una línea en el provider, y el contrato se pone rojo exactamente donde el cambio rompió una promesa —test_get_of_a_missing_id_raises[sqlite]— mientras el [fake], que no cambió, sigue verde. Y esto ocurrió al correr los tests, antes de desplegar. Sin el contrato, el cambio habría llegado a producción, y la primera señal habría sido que BookingService.cancel, que confía en que get lanza para un id ausente, un día recibe None y se rompe con un mensaje que no menciona ni al repositorio ni al cambio. El contrato convierte ese desastre en un test rojo local de dos centésimas de segundo.

Los dos lados, nombrados con precisión

Vale la pena fijar el vocabulario antes de que las lecciones lo usen sin parar, porque "consumer" y "provider" no son roles de una empresa ni tipos de test: son posiciones relativas a una costura.

  • El consumer es el componente que llama a través de la costura. En la costura del repositorio, el consumer es BookingService: es quien invoca save, get, find_by_room. Depende del comportamiento del otro lado; no lo implementa.
  • El provider es el componente que responde del otro lado de la costura. En la costura del repositorio, hay dos providers intercambiables: el FakeBookingRepository y el SqliteBookingRepository. Implementan el comportamiento; no lo llaman.
  • El contrato es el acuerdo sobre ese comportamiento, escrito una vez, que ambos lados deben respetar: qué recibe cada método, qué devuelve, qué lanza.

Un mismo componente puede ser consumer de una costura y provider de otra. BookingService es consumer frente al repositorio, pero podría ser provider frente a una capa web que lo llame. La palabra no describe qué es el componente, sino de qué lado de qué costura lo estás mirando. Por eso el módulo insiste en "el lado del consumer" y "el lado del provider": son puntos de vista sobre la misma costura, no cajas distintas.

Y de aquí sale la pregunta que la lección 7 responderá: si el contrato es un acuerdo entre dos, ¿quién lo define? La respuesta —el consumer— es lo que hace que estos contratos se llamen consumer-driven, y es lo que el concepto de Pact automatiza entre servicios. Por ahora basta con que veas la forma: dos lados, dos preguntas, un acuerdo.

Por qué separar los dos lados te da poder

Podrías preguntarte: si la batería ya corre contra ambos y da un veredicto, ¿para qué distinguir "el test del consumer" del "test del provider"? La respuesta es que la distinción te compra tres capacidades concretas que un veredicto único no da.

Verificas cada lado por su cuenta, con lo que ese lado necesita. El test del provider necesita el provider —el fake o SQLite— y nada más: no arranca BookingService, no arma un Calendar, no simula un pago. El test del consumer necesita el consumer —BookingService— contra cualquier provider que honre el contrato, y como el fake honra el contrato, el consumer se prueba sin base de datos, rápido y determinista. Separar los lados te deja probar cada uno con el setup mínimo de ese lado.

Localizas la culpa cuando algo falla. Si el test del provider [sqlite] está rojo pero el del consumer está verde, sabes que el problema vive en la implementación de SQLite, no en cómo BookingService la usa. Si fuera al revés, sabrías que el consumer supone de más. Un veredicto único te dice "algo no cumple el contrato"; los dos lados te dicen quién.

Cazas los cambios en el lugar correcto, antes del deploy. Un cambio en el provider —como el get que devuelve None— enrojece el lado [sqlite] sin tocar el [fake], porque el fake no cambió. Un cambio en las suposiciones del consumer —como asumir un orden no prometido— enrojece un test del consumer sin tocar ningún provider. Cada clase de error tiene su lado, y verlo en su lado te dice qué revisar. Esa correspondencia —error del provider → rojo del provider, suposición del consumer → rojo del consumer— es la que las lecciones 5 y 6 explotan a fondo.

El mapa del módulo

LecciónTemaLa idea en una frase
1El contrato tiene dos lados (esta)Consumer y provider: dos sillas, dos preguntas, un mismo contrato
2El lado del consumer"Yo mando X y espero Y": el consumer se apoya solo en lo prometido
3El lado del provider"Dado X, devuelvo Y": el provider cumple cada cláusula, aislado
4La misma batería contra ambosUna batería, dos providers: ese "dos" es toda la garantía
5Cazar un breaking change del providerEl provider rompe una promesa → rojo en [sqlite] antes del deploy
6Cazar una suposición de más del consumerEl consumer se apoya en lo no prometido → rojo del consumer
7Quién posee el contrato: consumer-drivenEl consumer define, el provider cumple; el concepto de Pact
8Mini-proyectoVerifica ambos lados y caza un breaking change que tú introduces

Las lecciones 2 y 3 te dan las dos sillas por separado; la 4 muestra por qué correr ambas con la misma batería es la garantía; las 5 y 6 son los dos pagos —cazar el error del provider y la suposición del consumer—; la 7 responde quién manda; y la 8 lo teje todo en un entregable.

Errores comunes

Creer que "consumer" y "provider" son tipos de componente, no posiciones. Qué pasa: alguien busca en el código "la clase Consumer" y no la encuentra, y se confunde. Por qué pasa: los nombres suenan a categorías fijas. Cómo detectarlo: si no puedes decir "consumer de qué costura", estás usando la palabra como etiqueta absoluta. Cómo corregirlo: siempre ancla el rol a una costura. BookingService es consumer de la costura del repositorio; SqliteBookingRepository es provider de esa misma costura. Cambia la costura y los roles cambian.

Pensar que el test del consumer necesita el provider real. Qué pasa: alguien monta una base de datos SQLite para probar BookingService, creyendo que "probar el consumer" exige el provider de verdad. Por qué pasa: se confunde el test del consumer con un test de integración. Cómo detectarlo: si tu test del consumer abre una conexión a la base de datos, cruzaste a integración (módulo 5) sin querer. Cómo corregirlo: el test del consumer corre contra cualquier provider que honre el contrato, y el fake honra el contrato —por eso el contrato existe—. Prueba el consumer contra el fake: rápido, sin base de datos, y aun así fiel, porque el contrato garantiza que el fake no miente.

Suponer que un contrato verde hoy seguirá verde solo. Qué pasa: el equipo ve los ocho verdes, respira tranquilo y deja de correr la batería en cada cambio del provider. Por qué pasa: un verde se siente como un logro permanente. Cómo detectarlo: si el contrato no corre en cada cambio del SqliteBookingRepository, un breaking change puede colarse sin que nadie lo vea hasta producción. Cómo corregirlo: el valor del contrato no es el verde de una vez, sino que vuelva a correr cada vez que el provider cambia. El breaking change de la lección 5 solo se caza porque la batería se corrió después del cambio. Un contrato que no se corre es un contrato que no protege.

Ejercicios

Ejercicio 1 — Nombra los dos lados. Reservo tiene otra costura además de la del repositorio: la del pago, donde BookingService llama a un PaymentGateway (con StubPaymentGateway y una implementación real). Para esa costura, di quién es el consumer, quiénes son los providers, y escribe en una frase una cláusula del contrato que ambos lados deberían respetar.

Ver solución
  • Consumer: BookingService, porque es quien llama a charge y refund a través de la costura del pago. Depende del comportamiento del gateway; no lo implementa.
  • Providers: las implementaciones del PaymentGateway —el StubPaymentGateway (doble) y el gateway real que hablaría con la pasarela de verdad—. Ambos deben responder igual ante la misma llamada.
  • Una cláusula del contrato: "charge(amount_cents) devuelve un Receipt con ok=True si el cobro se aprobó y ok=False si se rechazó; nunca devuelve None ni lanza por un rechazo normal". Ambos lados la respetan: el consumer se apoya en leer receipt.ok (y no en, digamos, que un rechazo lance una excepción), y cualquier provider promete devolver ese Receipt con el veredicto correcto.

Lo esencial: el rol lo fija la costura. BookingService era provider... no, era consumer del repositorio, y aquí es consumer del pago también —resulta que es consumer de las dos costuras—, pero eso es casualidad de este caso. Lo que no cambia es la regla: consumer = quien llama, provider = quien responde, contrato = lo que ambos respetan.

Ejercicio 2 — Predice qué lado se pone rojo. Para cada cambio, sin correr nada, di si enrojecería un test del provider [sqlite], un test del consumer, o ninguno: (a) SqliteBookingRepository.save deja de hacer commit, así que un get posterior no encuentra la fila; (b) BookingService empieza a suponer que find_by_room devuelve las reservas ordenadas por precio; (c) renombras una variable interna de SqliteBookingRepository.get sin cambiar su comportamiento.

Ver solución
  • (a) Provider [sqlite] rojo. Sin commit, guardar-y-leer deja de devolver la reserva, así que test_save_then_get_returns_the_same_booking[sqlite] falla. Es una promesa del contrato (guardar-y-leer devuelve la misma reserva) que la implementación rompió: rojo del lado del provider real, con [fake] intacto porque el fake no cambió.
  • (b) Consumer rojo. El contrato de find_by_room no promete ningún orden; si BookingService supone uno, se apoya en algo no prometido. Eso lo caza un test del consumer que lo corra contra un provider con otro orden legal (lección 6), no un test del provider —porque el provider no rompió nada, el consumer supuso de más—.
  • (c) Ninguno rojo. Renombrar una variable interna sin cambiar el comportamiento no toca ninguna promesa del contrato. El contrato afirma sobre el comportamiento observable (qué se recibe, qué se devuelve, qué se lanza), no sobre los nombres internos. Que un refactor inocuo no enrojezca el contrato es una virtud, no un descuido: un contrato que se rompe con cada renombre estaría sobre-especificado.

La moraleja: cada clase de error tiene su lado. Una promesa rota por la implementación → rojo del provider. Una suposición de más del que llama → rojo del consumer. Un cambio que no toca el comportamiento → ningún rojo. Saber predecir el lado es saber leer el contrato.

Ejercicio 3 — El valor del "antes". Explica, en tus palabras, por qué la frase "el contrato cazó el breaking change antes de desplegarlo" es el corazón del módulo, y qué habría pasado sin el contrato cuando get empezó a devolver None.

Ver solución

La palabra clave es antes. Sin contrato, un cambio en el provider (que get devuelva None en vez de lanzar) es sintácticamente válido —get sigue existiendo, sigue devolviendo algo— así que compila, pasa cualquier test que no ejercite el caso del id ausente, y se despliega. La primera señal del problema llega después, en producción, cuando algún camino que confiaba en que get lanzara —por ejemplo BookingService.cancel, que llama get y espera un KeyError para un id inexistente— recibe None, sigue adelante como si la reserva existiera, y falla más abajo con un error que no menciona ni al repositorio ni al cambio (un AttributeError sobre un None, quizás, a tres capas de distancia). El costo es alto: un incidente en producción, un rastreo cuesta arriba hasta la causa, y usuarios afectados.

Con el contrato, ese mismo cambio enrojece test_get_of_a_missing_id_raises[sqlite] en el momento en que corres la batería, en tu máquina, antes de fusionar nada. El error se presenta con nombre y apellido —la cláusula exacta que se rompió, en el provider exacto— y en dos centésimas de segundo. El contrato no evita que la gente cambie el provider; evita que un cambio incompatible llegue a producción sin ser visto. Mueve el descubrimiento del bug desde el lugar más caro (producción, después) al más barato (tu suite, antes). Eso —correr el riesgo hacia la izquierda, hacia el "antes"— es lo que hace del contrato una herramienta y no un adorno.

Resumen y siguiente paso

En esta lección abriste el contrato por la mitad y viste que siempre tiene dos lados: el del consumer (BookingService, el que usa la costura y pregunta "¿me apoyo solo en lo prometido?") y el del provider (SqliteBookingRepository, el que la implementa y pregunta "¿cumplo lo prometido?"). Con el enchufe y el tomacorriente entendiste que ambos verifican el mismo estándar desde sillas opuestas, sin coordinarse, y que el estándar es también el medidor que caza al que rompe el trato. Y viste el módulo entero condensado: la batería en verde por ambos lados —los ocho [fake]/[sqlite]—, y un cambio de una línea en el provider poniendo rojo el lado [sqlite] en la cláusula exacta, antes de desplegar.

Antes de avanzar deberías poder: definir consumer y provider como posiciones relativas a una costura, no como tipos de componente; nombrar las tres capacidades que da separar los dos lados (verificar cada uno por su cuenta, localizar la culpa, cazar el cambio en su lado); y explicar por qué "cazar el breaking change antes del deploy" es el pago central de la guía.

Lo que sigue es sentarse en la primera silla. La lección 2 escribe el lado del consumer: cómo se ve un test que mira la costura desde BookingService —"yo mando X y espero Y"— y, sobre todo, cómo ese test verifica que el consumer se apoya solo en lo que el contrato promete, sin suponer de más. Después, la lección 3 se cambia a la silla del provider.

Recursos