Módulo 3: Contract testing: el contrato consumer/provider

2. Qué es un contrato

Descripción

En la lección 1 definimos un contrato en una frase: un spec de comportamiento —no de forma— verificado igual contra todas las implementaciones. Esta lección desarma esa frase por su palabra más importante: comportamiento, en oposición a forma. La distinción parece de manual, pero es exactamente la grieta por la que se coló el bug del módulo 2, así que vale la pena mirarla de cerca hasta que sea evidente.

En Python hay dos cosas que solemos confundir porque suelen ir juntas: la interfaz de un componente y su contrato. La interfaz es la forma: qué métodos tiene, cómo se llaman, qué parámetros reciben, qué tipo devuelven. El FakeBookingRepository y el SqliteBookingRepository tienen la misma interfaz: los dos ofrecen save(booking), get(id) y find_by_room(room_id) con las mismas firmas. El contrato es el comportamiento: qué prometen esos métodos que ocurra cuando los llamas. Y ahí, con la misma interfaz, los dos repositorios del módulo 2 divergían: get de un id ausente lanzaba en uno y devolvía None en el otro. Misma forma, distinto comportamiento. Esa es la enfermedad que el contrato cura, y para curarla primero hay que verla con nitidez: la interfaz no captura el comportamiento, y por eso "tienen los mismos métodos" nunca fue una garantía de "se comportan igual".

Conexión con el módulo: esta lección es el cimiento conceptual del contrato. Una vez que separas forma de comportamiento, todo lo demás encaja: la lección 3 dirá quién define el comportamiento que se espera (el consumer); la 4 dará el mecanismo para verificarlo contra varias implementaciones (la batería parametrizada); la 5 lo cobrará cazando la divergencia del módulo 2. Pero nada de eso tiene sentido si "contrato" sigue sonando a "los métodos que tiene la clase". Aquí lo separamos: el contrato son las cuatro cláusulas de comportamiento del BookingRepository, y las escribimos como los tests que las verifican.

Analogía: el menú dice la forma; la receta dice el sabor

Entra a dos restaurantes que ofrecen exactamente el mismo plato en la carta: "Pasta al pesto, con albahaca, piñones y parmesano". La carta —la lista de ingredientes, el nombre del plato, el precio— es la interfaz: la forma, lo que se promete por escrito, idéntico en ambos locales. Pides el plato en los dos. En uno llega cremoso, con el pesto fresco y los piñones tostados; en el otro llega aguado, con la albahaca marchita y frío. Misma carta, comportamiento distinto. Lo que difiere no está en la lista de ingredientes: está en cómo cada cocina ejecuta el plato —la receta real, lo que ocurre de verdad en la sartén—.

La carta es la interfaz; la receta ejecutada es el contrato. Dos repositorios pueden anunciar el mismo "plato" —get(id) -> Booking— y servir cosas distintas: uno lanza cuando no hay reserva, el otro te trae un plato vacío (None) y te deja creer que había algo. Si eliges restaurante solo por la carta, te llevas sorpresas; si eliges por lo que de verdad llega a la mesa —el comportamiento—, sabes qué comes. Un contrato es escribir la receta esperada con tal precisión que puedas mandar el mismo plato a las dos cocinas y comprobar que ambas lo ejecutan igual. La carta idéntica nunca bastó; hay que probar el sabor.

La interfaz: la forma

Empecemos por lo que sí comparten el fake y el real. La interfaz del BookingRepository es este conjunto de firmas —los nombres de los métodos y las formas que mueven—:

# La INTERFAZ del BookingRepository (la forma, no el comportamiento)
class BookingRepository:
    def save(self, booking) -> None: ...
    def get(self, booking_id) -> "Booking": ...
    def find_by_room(self, room_id) -> list: ...

Tanto el FakeBookingRepository (un dict en memoria) como el SqliteBookingRepository (una tabla real) cumplen esta interfaz al pie de la letra. Los dos tienen save, get y find_by_room con esos parámetros. En Python, cumplir la interfaz es lo único que se necesita para que el código corra: BookingService llama self._repo.get(id) sin preguntar de qué clase es el repo, y funciona con cualquiera de los dos. Esa flexibilidad es una virtud —es lo que te deja sustituir el real por un fake en los tests—, pero trae escondido el peligro del módulo 2: como Python solo exige que los métodos existan, no que se comporten igual, dos implementaciones pueden pasar por la misma interfaz mientras difieren en lo que hacen. La forma no es el comportamiento.

El contrato: el comportamiento

El contrato es lo que la interfaz no dice: qué ocurre al llamar cada método, caso por caso. Para el BookingRepository, son las cuatro cláusulas que ya conociste en la lección 1, ahora escritas como lo que de verdad son —tests ejecutables—:

# El CONTRATO del BookingRepository (el comportamiento), como tests
# 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"]

Cada test es una cláusula, y cada cláusula es una promesa de comportamiento que la interfaz no puede expresar. La firma get(id) -> Booking no dice nada de qué pasa cuando el id no existe; la cláusula 2 lo fija: lanza. La firma de save no dice qué ocurre al repetir un id; la cláusula 3 lo fija: actualiza, no duplica. Ahí está toda la diferencia. La interfaz describe el canal (qué mando, qué recibo); el contrato describe la conducta (qué pasa en cada situación que me importa). El contrato es un superconjunto de la interfaz: la incluye —los métodos deben existir para poder probarlos— y le añade lo esencial que ella callaba.

Ejemplo trabajado: la misma forma, dos comportamientos

Veámoslo con el caso exacto del módulo 2, aislado a la cláusula que divergía. Aquí no parametrizamos todavía —eso es la lección 4—; solo ponemos frente a frente el fake correcto y el fake buggy para ver que la interfaz idéntica no impide comportamientos opuestos.

# tests/test_interface_is_not_contract.py
from reservo.doubles import BuggyFakeBookingRepository, FakeBookingRepository


# Misma llamada, misma interfaz: get de un id que no existe.
def test_correct_fake_raises_on_missing():
    repo = FakeBookingRepository()
    # Comportamiento correcto: lanza.
    try:
        repo.get("ghost")
        raised = False
    except KeyError:
        raised = True
    assert raised is True


def test_buggy_fake_returns_none_on_missing():
    repo = BuggyFakeBookingRepository()
    # Comportamiento divergente: NO lanza, devuelve None.
    result = repo.get("ghost")
    assert result is None

Los dos repositorios tienen la misma interfaz —get(id)—, y sin embargo hacemos dos aserciones opuestas sobre la misma llamada: uno lanza, el otro devuelve None. Y los dos tests pasan, porque cada uno describe el comportamiento real de su repositorio. Eso es la prueba viva de que la interfaz no es el contrato: aquí la forma es idéntica y el comportamiento es contradictorio.

Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):

python3 -m pytest tests/test_interface_is_not_contract.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items

tests/test_interface_is_not_contract.py::test_correct_fake_raises_on_missing PASSED [ 50%]
tests/test_interface_is_not_contract.py::test_buggy_fake_returns_none_on_missing PASSED [100%]

============================== 2 passed in 0.01s ==============================

Los dos verdes cuentan la historia entera. test_correct_fake_raises_on_missing pasa porque el fake correcto lanza; test_buggy_fake_returns_none_on_missing pasa porque el fake buggy devuelve None. Ambos cumplen la misma interfaz; ninguno "está roto" a nivel de sintaxis. La diferencia —lanzar contra devolver None— es puro comportamiento, y por eso solo un contrato (una cláusula que diga cuál de los dos es el correcto y se lo exija a ambos) puede resolver quién miente. La interfaz los da por buenos a los dos; el contrato elige uno y obliga al otro a alinearse o ponerse rojo. Esa es, exactamente, la lección 5.

Por qué la distinción importa tanto

Podrías preguntarte: si Python me deja sustituir un repo por otro con solo compartir la interfaz, ¿por qué obsesionarse con el comportamiento? Porque la sustitución es la fuente de tu confianza y también de tu riesgo. Sustituyes el real por el fake en los unit tests para ganar velocidad; esa sustitución solo es honesta si el fake se comporta como el real en todo lo que te importa. La interfaz garantiza que la sustitución compila y corre; el contrato garantiza que la sustitución no cambia el resultado. Sin contrato, cada sustitución es una apuesta: "espero que estos dos se comporten igual". El bug del módulo 2 fue esa apuesta perdida.

Hay un principio clásico detrás de esto, el de sustitución de Liskov, que en cristiano dice: si un componente promete una interfaz, cualquier implementación de esa interfaz debe poder usarse en su lugar sin sorpresas de comportamiento. La interfaz es la promesa de forma; el contrato es la promesa de "sin sorpresas". Cuando escribes el contrato como una batería y lo corres contra todas las implementaciones, estás haciendo cumplir ese principio de manera mecánica: ninguna implementación puede colarse con la forma correcta y el comportamiento equivocado, porque la batería la prueba de verdad.

Y hay un detalle práctico que redondea la idea: el contrato es tan grande como sus cláusulas. La interfaz de get es fija (una firma); el contrato de get es tan detallado como decidas escribirlo. Si solo escribes la cláusula del caso feliz (guardar y leer), tu contrato calla sobre el caso ausente —y ahí el fake y el real pueden divergir sin que nadie lo note—. Escribir un buen contrato es, sobre todo, decidir qué comportamientos son parte del acuerdo: los casos felices, sí, pero también los bordes que muerden —el id ausente, el id repetido, el filtro que no debe arrastrar de más—. Cada cláusula que añades cierra una puerta por la que una divergencia podría escaparse.

Errores comunes

Tratar los type hints como si fueran el contrato. Qué pasa: alguien anota def get(self, booking_id: str) -> Booking y siente que ya especificó el comportamiento. Por qué pasa: los type hints parecen precisos y dan una falsa sensación de rigor. Cómo detectarlo: el hint -> Booking no dice nada de qué pasa cuando no hay reserva —¿lanza?, ¿devuelve None?, ¿un Booking vacío?—; si tu "spec" no responde esa pregunta, es forma, no comportamiento. Cómo corregirlo: los hints documentan la interfaz (útil), pero el contrato son los tests que fijan la conducta en cada caso, incluidos los bordes. Un -> Booking y un test_get_of_a_missing_id_raises no compiten; el primero es la carta, el segundo la receta.

Escribir solo el caso feliz y creer que el contrato está completo. Qué pasa: la batería tiene la cláusula "guardar y leer devuelve la misma reserva" y nada más, y se declara victoria. Por qué pasa: el caso feliz es el que primero viene a la mente y el más satisfactorio de ver en verde. Cómo detectarlo: pregúntate por los bordes —id ausente, id repetido, lista vacía— y fíjate si alguna cláusula los cubre. Si no, el contrato calla justo donde suelen divergir las implementaciones. Cómo corregirlo: el bug del módulo 2 vivía en un borde (el id ausente), no en el caso feliz. Un contrato serio cubre los bordes que pueden hacer daño; son ellos, no el caso feliz, los que cazan las divergencias.

Confundir "el código corre" con "el comportamiento es correcto". Qué pasa: se sustituye el fake por el real, la app arranca sin errores de importación ni de atributo, y se concluye que la sustitución es sana. Por qué pasa: en Python, compartir la interfaz basta para que no explote al arrancar. Cómo detectarlo: que arranque solo prueba que la forma encaja; no dice nada del comportamiento en cada caso. Cómo corregirlo: la única prueba de que la sustitución es sana es correr el contrato —la batería de comportamiento— contra ambas implementaciones y verlas coincidir. "Corre" es la interfaz; "se comporta igual" es el contrato.

Ejercicios

Ejercicio 1 — Separa la carta de la receta. Clasifica cada frase como parte de la interfaz (forma) o del contrato (comportamiento) del BookingRepository: (a) "find_by_room devuelve una list"; (b) "find_by_room de una sala sin reservas devuelve una lista vacía, no lanza"; (c) "save acepta un Booking y no retorna valor"; (d) "después de save, la reserva es visible para find_by_room de su sala".

Ver solución
  • (a) Interfaz. El tipo de retorno (list) es forma. No dice qué contiene ni qué pasa en los bordes.
  • (b) Contrato. Describe qué ocurre en un caso concreto (sala sin reservas): devuelve vacío en vez de lanzar. Es comportamiento, y además un borde valioso —el tipo de caso donde las implementaciones suelen divergir—.
  • (c) Interfaz. Los tipos que recibe y retorna save: forma pura.
  • (d) Contrato. Relaciona dos operaciones (save y luego find_by_room) y fija qué debe observarse: comportamiento. Es una cláusula de consistencia entre métodos.

El patrón: si la frase habla de tipos y nombres, es interfaz; si habla de qué pasa cuando llamo, sobre todo en los bordes, es contrato.

Ejercicio 2 — La cláusula que faltaba. El contrato del ejemplo tiene cuatro cláusulas. Imagina que Reservo empieza a depender de que find_by_room devuelva las reservas ordenadas por start. El SqliteBookingRepository las devuelve en el orden en que están en la tabla; el FakeBookingRepository en el orden de inserción del dict. ¿El contrato actual protege contra una divergencia en el orden? Si no, escribe la cláusula que faltaría.

Ver solución

El contrato actual no protege el orden. La cláusula 4 solo verifica qué reservas devuelve find_by_room (las de la sala pedida, no las de otras), no en qué orden. Como ninguna cláusula menciona el orden, es un comportamiento fuera del acuerdo: el fake y el real podrían devolver las mismas reservas en órdenes distintos y las cuatro cláusulas seguirían verdes. Es justo el tipo de silencio por el que se cuela una divergencia —igual que el id ausente en el módulo 2—.

La cláusula que faltaría, escrita como test parametrizado:

# Clausula 5: find_by_room devuelve las reservas ordenadas por start.
def test_find_by_room_returns_bookings_ordered_by_start(repo):
    late = Booking(id="bk-late", room_id="focus", member_id="m-ana",
                   start=datetime(2026, 3, 10, 15), end=datetime(2026, 3, 10, 16),
                   status="confirmed", price_cents=2500)
    early = Booking(id="bk-early", room_id="focus", member_id="m-ana",
                    start=datetime(2026, 3, 10, 9), end=datetime(2026, 3, 10, 10),
                    status="confirmed", price_cents=2500)
    repo.save(late)      # se guarda primero el mas tardio
    repo.save(early)
    found = repo.find_by_room("focus")
    assert [b.id for b in found] == ["bk-early", "bk-late"]   # ordenado por start

Al añadirla y correr la batería, cualquier implementación que no ordene por start se pondría roja. Esto refuerza la lección: el contrato cubre lo que enuncia. Si el orden importa para el consumer, hay que hacerlo una cláusula; si no, se deja fuera a propósito. Decidir eso —qué entra al acuerdo— es el trabajo de diseñar un contrato.

Ejercicio 3 — Misma interfaz, comportamiento que muerde. Un compañero escribe un tercer repositorio, CachingBookingRepository, con la misma interfaz (save, get, find_by_room), que guarda en memoria una copia de lo último que leyó para ir más rápido. Por un bug, tras un save que actualiza una reserva, get sigue devolviendo la versión vieja desde la caché. ¿Qué cláusula del contrato lo cazaría, y qué te dice esto sobre por qué la interfaz no basta?

Ver solución

La cláusula que lo caza es la 3: "guardar dos veces el mismo id actualiza (no duplica)". El test guarda una reserva con status="confirmed", la vuelve a guardar con status="cancelled" y luego afirma que get devuelve status == "cancelled". El CachingBookingRepository buggy devolvería la versión vieja ("confirmed") desde su caché desactualizada, así que esa aserción fallaría en rojo —exactamente donde debe—.

Lo que esto revela es el corazón de la lección: el CachingBookingRepository tiene la interfaz perfecta. Nombres correctos, firmas correctas, arranca sin un solo error. Y aun así se comporta mal en una situación concreta (leer después de actualizar). Ninguna revisión de la forma podía atrapar ese bug, porque la forma está impecable. Solo el contrato —una cláusula que prueba el comportamiento de actualizar-y-leer— lo revela. Cada implementación nueva de una interfaz es una oportunidad nueva de divergir; el contrato es lo que las mantiene a todas honestas sin que tengas que revisar el comportamiento a mano una por una.

Resumen y siguiente paso

En esta lección separaste dos cosas que solían venir pegadas: la interfaz (la forma —nombres, firmas, tipos—) y el contrato (el comportamiento —qué prometen esos métodos que ocurra—). Con la carta y la receta viste que dos cocinas pueden anunciar el mismo plato y servir sabores distintos; con el fake correcto y el buggy viste, en verde, que dos repositorios con la misma interfaz pueden comportarse de forma opuesta ante la misma llamada. Y entendiste por qué esto importa tanto: la sustitución de un doble por lo real es honesta solo si comparten el comportamiento, no basta con que compartan la forma —y la interfaz, por sí sola, jamás lo garantizó—.

Antes de avanzar deberías poder: distinguir una frase de interfaz de una de contrato; enunciar las cuatro cláusulas de comportamiento del BookingRepository y reconocer que cubren tanto casos felices como bordes; y explicar por qué "tienen los mismos métodos" nunca implicó "se comportan igual", con el bug del módulo 2 como prueba.

Ya sabes qué es un contrato (comportamiento, no forma). Falta la pregunta de quién decide ese comportamiento: ¿lo define el que implementa el repositorio, o el que lo usa? En la lección 3 verás que manda el consumerBookingService, el que usa el repo— porque son sus necesidades las que se convierten en cláusulas. Esa idea, la de los contratos consumer-driven, es la que da nombre a toda la disciplina.

Recursos