Módulo 4: Verificar el contrato desde ambos lados

6. Cazar una suposición de más del consumer

Descripción

La lección 5 cazó a un provider que rompe una promesa. Esta caza el error espejo: un consumer que se apoya en una promesa que nunca se hizo. Son las dos únicas formas de romper un contrato. En la primera, el acuerdo dice X y el provider deja de cumplir X. En la segunda, el consumer da por hecho Y, pero Y no está en el acuerdo —es un detalle que el provider actual casualmente tiene, no algo que prometió—. El primer error lo caza el lado del provider; el segundo, más escurridizo, lo caza el lado del consumer, y solo si sabes buscarlo.

La suposición de más es escurridiza porque funciona. El consumer que se apoya en un detalle no prometido no falla hoy: su provider actual tiene ese detalle, así que el código corre, los tests pasan, todo verde. La bomba está armada para el día en que el provider cambie —a otra implementación igualmente válida, a una versión nueva, a un motor de base de datos distinto— y el detalle no prometido desaparezca. Entonces el consumer se rompe, y la culpa parece del provider ("¡cambió y me rompió!"), cuando en realidad el provider cumplió el contrato al pie de la letra y fue el consumer quien se apoyó en algo fuera de él. En Reservo, el caso concreto: BookingService supone que find_by_room devuelve las reservas ordenadas por start, y el contrato solo promete el conjunto de reservas, sin orden alguno. Esta lección enseña la técnica para cazar esa suposición antes de que estalle: correr el consumer contra un provider que devuelve un orden legal distinto.

Conexión con el módulo: cierra el par que abrió la lección 5. Juntas cubren las dos direcciones del incumplimiento —provider que rompe una promesa (5), consumer que inventa una (6)— y completan la idea de la lección 2, donde se plantó la semilla: "el consumer debe apoyarse solo en lo prometido". Aquí esa frase se vuelve una prueba concreta con salida real. La lección 7 subirá un nivel para preguntar quién define qué está prometido, que es exactamente lo que decide si una suposición es legítima o de más.

Analogía: el que memorizó el cajón en vez de leer la etiqueta

Imagina una farmacia con un acuerdo claro: cada medicamento vive en un cajón etiquetado con su nombre, y el acuerdo —el contrato— es "el cajón dice qué contiene". Un empleado nuevo aprende a leer la etiqueta: para dar ibuprofeno, busca el cajón que dice "ibuprofeno". Se apoya en lo prometido: la etiqueta. Otro empleado, más veterano, nunca lee las etiquetas: memorizó que el ibuprofeno "está en el tercer cajón de la segunda fila". Y funciona —durante años— porque en esa farmacia el ibuprofeno lleva años en ese cajón. Se apoya en algo que el acuerdo no promete: la posición.

El día que reorganizan la estantería —algo perfectamente legítimo, porque el acuerdo nunca prometió posiciones, solo etiquetas—, el empleado que lee etiquetas sigue trabajando sin enterarse del cambio. El que memorizó posiciones entrega el contenido del tercer cajón de la segunda fila, que ahora es otra cosa, y jura que "movieron todo mal". Pero nadie rompió el acuerdo: los cajones siguen etiquetados correctamente. Fue él quien se apoyó en la posición, un detalle no prometido, en vez de la etiqueta, lo único garantizado.

find_by_room es la estantería: el contrato promete qué reservas hay (las de esa sala, como las etiquetas prometen qué contiene cada cajón), pero no promete el orden (como no promete posiciones fijas). El consumer que hace find_by_room(...)[0] para obtener "la primera" es el empleado que memorizó posiciones: funciona mientras el provider devuelva las reservas en cierto orden, y se rompe en silencio el día que un provider igualmente válido las devuelva en otro. La técnica de esta lección es contratar a un evaluador que deliberadamente reorganiza la estantería antes de probar al empleado —para ver si de verdad lee etiquetas o si memorizó posiciones—.

Ejemplo trabajado: el consumer que supone un orden

Aquí está el consumer bajo sospecha. BookingService quiere "la reserva más temprana de una sala", y lo resuelve tomando el primer elemento de find_by_room, suponiendo que la lista viene ordenada por start ascendente:

# reservo/scheduling.py — logica del consumer que usa find_by_room
def earliest_booking_over_assuming(repo, room_id):
    # SUPONE DE MAS: que find_by_room ya viene ordenado por start ascendente,
    # algo que el contrato NUNCA prometio.
    return repo.find_by_room(room_id)[0]


def earliest_booking_correct(repo, room_id):
    # Se apoya SOLO en lo que el contrato promete: el conjunto de reservas,
    # sin ningun supuesto de orden. Si necesita orden, lo impone el consumer.
    return min(repo.find_by_room(room_id), key=lambda b: b.start)

Las dos funciones responden la misma pregunta —"¿cuál es la reserva más temprana?"— pero se apoyan en cosas distintas. La primera, over_assuming, cuelga de que find_by_room devuelva las reservas ya ordenadas por start; toma [0] y confía. La segunda, correct, no supone nada del orden: recibe el conjunto y calcula el mínimo por start ella misma. Si el contrato promete el conjunto pero no el orden, solo la segunda es correcta —la primera es una bomba de tiempo—.

Ahora la técnica para cazarla. La clave es el helper que prepara el repositorio: guardamos las reservas fuera de orden de start —primero la de las 11, luego la de las 9—. Esto es legal: el contrato no dice en qué orden guardas ni en qué orden se devuelven. Un provider que devuelva las reservas en orden de inserción (como el fake, o como SQLite sin ORDER BY) las devolverá [11am, 9am], y ahí la suposición de más queda al descubierto:

# tests/test_consumer_over_assumption.py
from datetime import datetime

from reservo.doubles import FakeBookingRepository
from reservo.models import Booking
from reservo.scheduling import (earliest_booking_correct,
                                earliest_booking_over_assuming)


def booking(id, hour):
    start = datetime(2026, 3, 10, hour)
    end = datetime(2026, 3, 10, hour + 1)
    return Booking(id=id, room_id="focus", member_id="m-ana",
                   start=start, end=end, status="confirmed", price_cents=6000)


def a_repo_saved_out_of_start_order():
    repo = FakeBookingRepository()
    repo.save(booking("bk-11am", 11))    # guardada PRIMERO
    repo.save(booking("bk-9am", 9))      # guardada DESPUES (empieza antes)
    return repo                          # find_by_room -> [11am, 9am] (orden legal)


# el consumer supone de mas: cree que [0] es la mas temprana
def test_over_assuming_consumer_returns_the_wrong_booking():
    repo = a_repo_saved_out_of_start_order()
    earliest = earliest_booking_over_assuming(repo, "focus")
    assert earliest.id == "bk-9am"       # espera la de las 9; el contrato no promete orden


# el consumer correcto solo se apoya en el conjunto y ordena por su cuenta
def test_correct_consumer_returns_the_earliest_regardless_of_order():
    repo = a_repo_saved_out_of_start_order()
    earliest = earliest_booking_correct(repo, "focus")
    assert earliest.id == "bk-9am"

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

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

tests/test_consumer_over_assumption.py::test_over_assuming_consumer_returns_the_wrong_booking FAILED [ 50%]
tests/test_consumer_over_assumption.py::test_correct_consumer_returns_the_earliest_regardless_of_order PASSED [100%]

=================================== FAILURES ===================================
____________ test_over_assuming_consumer_returns_the_wrong_booking _____________

    def test_over_assuming_consumer_returns_the_wrong_booking():
        repo = a_repo_saved_out_of_start_order()
        earliest = earliest_booking_over_assuming(repo, "focus")
>       assert earliest.id == "bk-9am"       # espera la de las 9; el contrato no promete orden
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       AssertionError: assert 'bk-11am' == 'bk-9am'
E         
E         - bk-9am
E         ?    ^
E         + bk-11am
E         ?    ^^

tests/test_consumer_over_assumption.py:28: AssertionError
========================= 1 failed, 1 passed in 0.01s ==========================

Ahí está la suposición de más, cazada. El consumer over_assuming devolvió 'bk-11am' cuando la reserva más temprana es 'bk-9am': tomó el [0] de una lista que venía en orden de inserción, no de start, y se equivocó. El consumer correct, que calcula el mínimo por start sin suponer orden, devolvió la correcta. La técnica funcionó porque preparamos el repositorio fuera de orden a propósito: le dimos al provider una excusa perfectamente legal para devolver las reservas en un orden que el consumer no esperaba, y así el consumer que se apoyaba en el orden se delató. Un provider que casualmente devolviera todo ordenado habría dejado pasar el bug; el truco es no darle esa casualidad.

La técnica, nombrada: el provider adversario-pero-legal

Lo que acabas de hacer tiene una forma que conviene fijar, porque es la manera de cazar suposiciones de más. La idea es esta: para verificar que un consumer se apoya solo en lo prometido, córrelo contra un provider que cumpla el contrato al mínimo legal, eligiendo a propósito el comportamiento más incómodo que el contrato aún permite. Si el contrato no promete orden, tu provider de prueba devuelve un orden raro pero legal. Si el contrato permite None en un campo opcional, tu provider devuelve None. Si el contrato no promete que dos llamadas devuelvan el mismo objeto, tu provider devuelve copias distintas. Es un provider adversario dentro de la ley: no viola el contrato, pero explota cada libertad que el contrato le deja.

¿Por qué funciona? Porque un consumer que se apoya solo en lo prometido sobrevive a cualquier provider legal, incluido el más incómodo. Y un consumer que supone de más se rompe justo contra las libertades que explota el adversario. El provider adversario-pero-legal convierte una suposición tácita ("seguro viene ordenado") en un fallo visible. En nuestro caso, a_repo_saved_out_of_start_order es ese adversario: usa el fake —que honra el contrato— pero lo alimenta de modo que devuelva el orden más molesto que el contrato permite. No mentimos sobre el contrato; explotamos que el contrato no promete orden.

Contrasta esto con el consumer test "amable" de la lección 2, que usaba datos cómodos y pasaba. Un consumer test amable verifica que el consumer funciona en el caso feliz; un consumer test adversario-pero-legal verifica que el consumer no se apoya en nada fuera del contrato. Los dos son útiles: el amable documenta el uso normal, el adversario caza la fragilidad. Para suposiciones de más, necesitas el adversario.

Las dos curas: apretar el consumer o ampliar el contrato

Cuando cazas una suposición de más, tienes exactamente dos salidas legítimas, y elegir la correcta es una decisión de diseño, no de gusto.

Cura A — apretar el consumer para que no suponga de más. Si el orden no debería ser parte del contrato —porque otros providers razonablemente no lo garantizarían, o porque pedirlo tendría un costo—, entonces el arreglo es que el consumer deje de depender de él. Es lo que hace earliest_booking_correct: recibe el conjunto y ordena por su cuenta con min(..., key=lambda b: b.start). El consumer se hace responsable de lo que necesita en vez de exigírselo al provider. Esta es la cura por defecto: mantiene el contrato mínimo y empuja la responsabilidad a quien tiene el requisito.

Cura B — ampliar el contrato para que el orden sea una promesa. Si resulta que muchos consumers necesitan las reservas ordenadas por start, y tiene sentido que todo provider lo garantice, entonces la salida es añadir el orden como cláusula del contrato: un test_find_by_room_returns_bookings_sorted_by_start que corra contra ambos providers, obligando al fake a ordenar y a SQLite a añadir ORDER BY start. Con eso, el orden deja de ser una suposición de más y pasa a ser una promesa legítima, garantizada por la batería contra los dos lados. El consumer ya puede apoyarse en el orden, porque ahora está prometido.

La pregunta que decide entre A y B es: ¿debería este comportamiento ser una promesa de todo provider, o es un requisito de este consumer? Si es del consumer, cura A (que él se lo resuelva). Si debe ser de todo provider, cura B (súbelo al contrato). Lo que no es una salida legítima es dejar la suposición de más sin tratar —ni apretar el consumer ni ampliar el contrato—, porque eso deja la bomba armada. Cazarla obliga a decidir; la decisión es A o B, nunca "no hago nada".

Errores comunes

Escribir el consumer test con datos ya ordenados. Qué pasa: alguien prueba earliest_booking guardando las reservas en orden de start (9am y luego 11am), el [0] da la correcta, y el test pasa —ocultando la suposición de más—. Por qué pasa: es natural preparar los datos en orden "natural". Cómo detectarlo: si tu consumer test nunca alimenta al provider con un orden incómodo, no puede cazar una dependencia de orden. Cómo corregirlo: para probar que el consumer no supone orden, guarda a propósito fuera de orden. El dato de prueba incómodo no es un capricho: es lo único que ejercita la libertad que el contrato deja y que el consumer podría estar violando. Un consumer test que solo usa datos cómodos es un ensayo con red: no prueba la caída real.

Confundir "el fake lo devolvió así" con "el contrato lo promete". Qué pasa: alguien ve que el fake devuelve las reservas en orden de inserción y concluye "entonces el contrato garantiza orden de inserción". Por qué pasa: se toma el comportamiento observado de una implementación como si fuera el acuerdo. Cómo detectarlo: pregúntate si ese comportamiento está escrito como cláusula en la batería. El orden de inserción del fake no tiene ningún test_... que lo prometa; es un accidente de que un dict preserva inserción. Cómo corregirlo: el contrato es lo escrito y verificado contra ambos lados, no lo que una implementación resulta hacer. Si no hay una cláusula que promete el orden, el orden no está prometido —por más que el fake lo haga hoy—. Apoyarse en lo observado en vez de lo prometido es la definición misma de la suposición de más.

"Arreglar" el rojo cambiando el dato de prueba en vez del consumer. Qué pasa: alguien ve test_over_assuming_... en rojo y "lo arregla" volviendo a guardar las reservas en orden, para que [0] dé la correcta. Por qué pasa: reordenar el dato hace desaparecer el rojo con menos esfuerzo que arreglar el consumer. Cómo detectarlo: si tu arreglo consistió en acomodar los datos para que la suposición de más vuelva a funcionar, no arreglaste nada: rearmaste la bomba y apagaste la alarma. Cómo corregirlo: el rojo está diciendo "el consumer se apoya en algo no prometido"; la respuesta es una de las dos curas (apretar el consumer o ampliar el contrato), no maquillar el dato. El test adversario debe seguir alimentando fuera de orden; lo que cambia es el consumer (o el contrato), no el dato que lo delata.

Ejercicios

Ejercicio 1 — Otra suposición de más. BookingService tiene un método que hace repo.get(booking_id).start.strftime("%H:%M") para mostrar la hora de inicio. ¿Qué está suponiendo de más sobre lo que get devuelve, en qué provider se rompería, y cómo lo cazarías?

Ver solución

Qué supone de más: que get devuelve un start de tipo datetime (porque .strftime(...) es un método de datetime). El contrato promete que get devuelve una reserva con los campos correctos —y en nuestro contrato, gracias a la cláusula de guardar-y-leer que verifica got.start == START como datetime, el tipo está prometido—. Pero si el contrato no verificara el tipo del start (si solo comparara, digamos, el id y el price_cents), entonces suponer que es un datetime sería una suposición de más.

En qué provider se rompería: en cualquiera que devolviera el start como str en vez de datetime —exactamente el SqliteBookingRepository roto del módulo 1, antes del arreglo con fromisoformat—. Un str no tiene .strftime, así que reventaría con AttributeError: 'str' object has no attribute 'strftime'.

Cómo lo cazarías: con un provider adversario-pero-legal que devuelva el start como str (si el contrato permitiera texto) y corriendo el consumer contra él; o, mejor, garantizando en el contrato que start es un datetime (la cláusula que ya tenemos) para que la suposición deje de ser "de más" y pase a ser legítima. Este ejercicio muestra el vínculo con el módulo 1: aquella divergencia del datetime era, vista desde este ángulo, una suposición de más del consumer sobre el tipo —y la cura fue la cura B: subir el tipo al contrato y verificarlo contra ambos lados—.

Ejercicio 2 — Elige la cura. Para cada caso, decide si la cura correcta es A (apretar el consumer) o B (ampliar el contrato), y justifica: (a) un solo reporte interno necesita las reservas ordenadas por precio; (b) todos los consumers que listan reservas para un usuario las quieren ordenadas por fecha, de la más reciente a la más antigua, y sería raro que un provider no lo garantizara.

Ver solución
  • (a) Ordenar por precio para un reporte interno → cura A (apretar el consumer). Es un requisito de un consumer específico y peculiar (ordenar por precio no es una necesidad general de "listar reservas"). Obligar a todo provider a saber ordenar por precio sería cargar el contrato con una promesa que casi nadie usa. Lo correcto es que el reporte reciba el conjunto y ordene por precio él mismo (sorted(bookings, key=lambda b: b.price_cents)). El requisito vive donde nace: en el consumer que lo tiene.
  • (b) Todos los consumers quieren orden por fecha descendente → cura B (ampliar el contrato). Cuando muchos consumers necesitan lo mismo y es razonable exigírselo a todo provider, subirlo al contrato evita que cada consumer reimplemente el orden (y evita que uno se olvide y quede frágil). Se añade una cláusula test_find_by_room_returns_bookings_sorted_by_start_desc verificada contra ambos providers, y a partir de ahí el orden es una promesa legítima en la que todos pueden apoyarse.

La regla de decisión: ¿lo necesita este consumer, o todo consumer? Un requisito idiosincrático se resuelve en el consumer (A); un requisito compartido y razonable se promueve a promesa del contrato (B). El costo de equivocarse: hacer B cuando debía ser A infla el contrato con promesas que atan a todo provider sin necesidad; hacer A cuando debía ser B disemina la misma lógica de orden por todos los consumers, invitando a que uno la olvide.

Ejercicio 3 — Un provider adversario explícito. En vez de alimentar el fake fuera de orden, escribe un pequeño provider ShufflingRepository que envuelva a otro repositorio y devuelva find_by_room en orden invertido —legal, porque el contrato no promete orden—. Explica por qué correr los consumers contra él es una forma más sistemática de cazar suposiciones de orden que preparar los datos a mano.

Ver solución

Un provider adversario explícito:

class ShufflingRepository:
    def __init__(self, inner):
        self._inner = inner

    def save(self, booking):
        self._inner.save(booking)

    def get(self, booking_id):
        return self._inner.get(booking_id)          # se apoya en el contrato del inner

    def find_by_room(self, room_id):
        return list(reversed(self._inner.find_by_room(room_id)))   # orden legal, incomodo

Envuelve cualquier repositorio que honre el contrato (por ejemplo el fake) y devuelve las reservas al revés. Como el contrato no promete orden, invertir es legal: ShufflingRepository sigue honrando el contrato (guarda, lee, lanza en el id ausente, filtra por sala) pero explota la libertad de orden al máximo.

Por qué es más sistemático que preparar los datos a mano: con el dato-a-mano, cada consumer test tiene que acordarse de guardar fuera de orden, y es fácil olvidarlo (y volver a los datos cómodos). Con el ShufflingRepository, envuelves cualquier provider una vez y todos los consumer tests que corran contra él quedan expuestos a un orden incómodo automáticamente, sin depender de cómo se prepararon los datos. Es la técnica del provider adversario-pero-legal hecha reutilizable: un envoltorio que fuerza la libertad del contrato, de modo que cualquier consumer que suponga orden se delate sin que tengas que recordar tenderle la trampa. Lo mismo se puede hacer para otras libertades (un CopyingRepository que devuelva copias en get, para cazar suposiciones de identidad de objeto).

Resumen y siguiente paso

En esta lección cazaste el error espejo del breaking change: una suposición de más del consumer. BookingService daba por hecho que find_by_room venía ordenado por start —tomaba [0] como "la más temprana"—, cuando el contrato solo promete el conjunto de reservas, sin orden. Con el empleado que memorizó el cajón en vez de leer la etiqueta entendiste por qué la suposición funciona hoy y estalla mañana: se apoya en un detalle que el provider actual casualmente tiene, no en algo prometido. Y viste la técnica para delatarla —el provider adversario-pero-legal: alimentar al provider de modo que devuelva el orden más incómodo que el contrato aún permite— con salida real: el consumer que supone orden devolvió 'bk-11am' en vez de 'bk-9am', mientras el consumer correcto, que ordena por su cuenta, dio la respuesta buena. Y nombraste las dos curas —apretar el consumer (A) o ampliar el contrato (B)—, decididas por una sola pregunta: ¿lo necesita este consumer o todo provider debería prometerlo?

Antes de avanzar deberías poder: distinguir una suposición de más de una dependencia legítima (¿está escrita como cláusula, o solo la hace el provider actual?); usar un provider adversario-pero-legal para cazarla; elegir entre apretar el consumer y ampliar el contrato; y descartar el falso arreglo de reordenar el dato para maquillar el rojo.

Con las lecciones 5 y 6 tienes las dos formas de romper un contrato, cada una cazada en su lado. Queda la pregunta de gobierno que ambas rozaron: cuando el consumer supone algo y el provider hace otra, ¿quién tiene razón? ¿Quién define qué está prometido? La lección 7 responde: el consumer posee el contrato —es consumer-driven—, y explica cómo esa idea se automatiza entre servicios en el concepto de Pact.

Recursos