Módulo 3: Contract testing: el contrato consumer/provider
6. Contratos de estado contra contratos de interacción
Descripción
Hasta ahora todas nuestras cláusulas tenían la misma forma: llamábamos un método y afirmábamos sobre lo que devolvía o sobre el estado observable que dejaba. "Guardar y leer devuelve la misma reserva." "find_by_room devuelve solo las de esa sala." Afirmamos sobre el resultado. Esa es una manera de escribir un contrato —la más común para un repositorio—, pero no es la única. Hay una segunda forma, y esta lección la introduce: los contratos que afirman no sobre el resultado, sino sobre la llamada —a quién se le llamó, con qué argumentos, cuántas veces—.
Los nombres son contrato de estado y contrato de interacción. Un contrato de estado verifica el qué quedó: guardas una reserva y compruebas que get la devuelve con price_cents == 6000. No te importa cómo el provider llegó ahí; te importa el resultado observable. Un contrato de interacción verifica el qué pasó entre las piezas: reservas y compruebas que BookingService llamó a payments.charge exactamente una vez, con 6000 centavos. No hay un "resultado" que inspeccionar —cobrar no deja un estado que puedas leer de vuelta como una reserva guardada—; lo que verificas es que la conversación entre el consumer y el provider ocurrió como el contrato manda. Cada colaborador de Reservo pide una u otra forma según su naturaleza: el repositorio, que guarda estado, se presta al contrato de estado; el PaymentGateway, que ejecuta una acción con efectos afuera, se presta al de interacción.
Conexión con el módulo: esta lección afina el instrumento que ya dominas. Sabes escribir un contrato como batería parametrizada (lección 4) y sabes que caza divergencias (lección 5); ahora aprendes que una cláusula puede escribirse de dos maneras, y a elegir la adecuada según el colaborador. Es la última pieza conceptual antes de la lección 7, donde el concepto de Pact reúne ambos estilos en la comunicación entre servicios de red. Con estado e interacción claros, tienes el vocabulario completo para razonar cualquier contrato: qué comportamiento (lección 2), de quién (lección 3), verificado cómo (lección 4), y ahora, afirmado sobre qué —el resultado o la llamada—.
Analogía: la foto del resultado contra la grabación de la conversación
Imagina que contratas a un mensajero para entregar un paquete y quieres verificar que hizo bien su trabajo. Tienes dos formas de comprobarlo. La primera: vas a la casa del destinatario y miras si el paquete está ahí. Si está, y en buen estado, el trabajo se hizo —no te importa qué ruta tomó el mensajero, cuántas paradas hizo, si fue en moto o a pie—. Verificaste el resultado: una foto del estado final. La segunda forma sirve para cuando no puedes ir a la casa —el paquete es un mensaje verbal que no deja rastro físico—: entonces grabas al mensajero y compruebas que dijo las palabras correctas, al destinatario correcto, una sola vez. Verificaste la interacción: la grabación de la conversación.
El contrato de estado es la foto del resultado; el de interacción es la grabación de la conversación. Para el repositorio, puedes ir a "la casa" —el almacén— y mirar si la reserva quedó: contrato de estado. Para el pago, no hay una casa que visitar dentro de tu test —cobrar una tarjeta tiene su efecto en el banco, afuera, no en un estado que puedas leer de vuelta—; lo que sí puedes es grabar que BookingService le dijo a payments: "cobra 6000", una vez: contrato de interacción. La elección no es de gusto: depende de si el efecto del colaborador deja un estado que puedas inspeccionar (foto) o se va afuera y solo queda la conversación (grabación).
El contrato de estado: afirmar sobre el resultado
Ya escribiste muchos; démosles nombre. Un contrato de estado sigue el patrón actuar, luego observar el resultado:
# Contrato de ESTADO: se afirma sobre el RESULTADO observable (la fila guardada).
def test_state_contract_saved_booking_has_the_right_price():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = BookingService(Calendar(), FixedClock(NOW),
MockPaymentGateway(), SpyEmailSender(), repo)
booking = service.book(FOCUS, ANA, START, END)
assert repo.get(booking.id).price_cents == 6000 # <-- observo el estado
La aserción mira el mundo después de la acción: pide la reserva guardada y comprueba su price_cents. No dice nada de cómo book calculó el precio ni en qué orden hizo las cosas; solo verifica que el resultado observable —la reserva persistida— es el correcto. Este estilo es el natural cuando el colaborador guarda estado que puedes leer de vuelta: el repositorio es el caso perfecto, porque su razón de ser es dejar reservas que luego se recuperan. Las cuatro cláusulas del contrato del BookingRepository de las lecciones anteriores son todas de estado: guardas y lees, guardas dos veces y lees, filtras y lees. Siempre observas el resultado.
La virtud del contrato de estado es que es robusto frente a los detalles de implementación. Como solo mira el resultado, el provider es libre de llegar a él como quiera —un dict, una tabla, una caché— y la cláusula sigue valiendo para todos. Por eso encaja tan bien con la batería parametrizada: "el resultado observable es X" es cumplible por cualquier implementación, y es justo lo que quieres exigirle a todas por igual.
El contrato de interacción: afirmar sobre la llamada
Ahora el otro estilo. A veces no hay un estado que observar, o el que importa no es el resultado sino que la conversación ocurrió bien. Cobrar es el ejemplo canónico: cuando BookingService.book cobra, el efecto real —mover dinero— ocurre afuera, en el gateway, y dentro de tu test no hay una "reserva de cobro" que leer de vuelta. Lo que sí puedes verificar es que book le habló al gateway correctamente: que llamó a charge una vez, con el monto correcto. Para eso usamos un doble que registra las llamadas —un mock— y afirmamos sobre lo registrado:
class MockPaymentGateway:
"""Registra las llamadas: sirve para verificar la INTERACCION, no el estado."""
def __init__(self):
self.calls = []
def charge(self, amount_cents):
self.calls.append(amount_cents) # <-- anota la llamada
return Receipt(id="rcpt-1", ok=True, amount_cents=amount_cents)
# Contrato de INTERACCION: se afirma sobre la LLAMADA (a quien, con que, cuantas veces).
def test_interaction_contract_charge_is_called_once_with_the_price():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
payments = MockPaymentGateway()
service = BookingService(Calendar(), FixedClock(NOW),
payments, SpyEmailSender(), repo)
service.book(FOCUS, ANA, START, END)
assert payments.calls == [6000] # una sola llamada, por 6000 centavos
La aserción no mira ningún resultado devuelto; mira la lista de llamadas que el mock fue anotando. payments.calls == [6000] dice tres cosas a la vez: que a charge se le llamó (la lista no está vacía), que se le llamó una sola vez (la lista tiene un elemento, no dos —importante: no cobramos dos veces—), y que se le llamó con el monto correcto (6000 centavos, el precio de Focus 3 h pro). Eso es un contrato de interacción: verifica el protocolo de la conversación entre el consumer y el provider.
Este estilo es el natural cuando el colaborador ejecuta una acción con efecto afuera —cobrar, enviar un correo, publicar un mensaje— y lo que te importa proteger es que se le invoque correctamente: la vez justa, con los datos justos. Cobrar dos veces es un bug grave que ningún contrato de estado del gateway cazaría (no hay estado que leer); un contrato de interacción que afirme calls == [6000] lo caza al instante, porque [6000, 6000] no es igual a [6000].
Ejemplo trabajado: los dos estilos, lado a lado
Corramos los dos tests juntos —uno de estado sobre el repositorio, uno de interacción sobre el gateway— para ver que ambos son cláusulas legítimas, cada una en su terreno:
# tests/test_state_vs_interaction.py (extracto; imports y constantes arriba)
# ESTADO: el repositorio guarda una reserva con el precio correcto.
def test_state_contract_saved_booking_has_the_right_price():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = BookingService(Calendar(), FixedClock(NOW),
MockPaymentGateway(), SpyEmailSender(), repo)
booking = service.book(FOCUS, ANA, START, END)
assert repo.get(booking.id).price_cents == 6000
# INTERACCION: a charge se le llama una vez, con 6000.
def test_interaction_contract_charge_is_called_once_with_the_price():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
payments = MockPaymentGateway()
service = BookingService(Calendar(), FixedClock(NOW),
payments, SpyEmailSender(), repo)
service.book(FOCUS, ANA, START, END)
assert payments.calls == [6000]
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_state_vs_interaction.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items
tests/test_state_vs_interaction.py::test_state_contract_saved_booking_has_the_right_price PASSED [ 50%]
tests/test_state_vs_interaction.py::test_interaction_contract_charge_is_called_once_with_the_price PASSED [100%]
============================== 2 passed in 0.01s ==============================
Dos verdes, dos estilos. El primero afirma sobre el resultado (la reserva guardada tiene el precio correcto); el segundo afirma sobre la llamada (a charge se le invocó una vez con 6000). El mismo book produjo ambas verdades —guardó bien y cobró bien—, pero las verificamos de formas distintas porque los colaboradores son de naturaleza distinta. El repositorio deja un estado que leemos; el gateway ejecuta una acción cuya corrección vive en cómo se le llamó. Elegir el estilo correcto para cada uno es lo que hace que cada cláusula sea a la vez fiel (prueba lo que importa) y robusta (no se rompe por detalles que no importan).
Cuál elegir (y el riesgo de cada uno)
La regla práctica es directa: si el colaborador deja un estado observable que el consumer usa, contrato de estado; si ejecuta una acción con efecto afuera y lo que importa es que se le invoque bien, contrato de interacción. Repositorio → estado. Gateway de pago, envío de correo, publicación en una cola → interacción. Muchos colaboradores admiten los dos, y a veces quieres ambos: para el gateway podrías verificar por interacción que se llamó a charge una vez con 6000 (protocolo) y por estado que la reserva quedó confirmed solo si el cobro salió bien (resultado).
Cada estilo tiene su riesgo, y conviene conocerlos. El contrato de estado es robusto pero a veces ciego a lo que no dejó rastro: si book cobrara dos veces pero el estado final (la reserva guardada) se viera igual, un contrato de estado del repositorio no lo notaría —el doble cobro no dejó estado que leer—. Ahí necesitas interacción. El contrato de interacción, en cambio, es más frágil frente a la implementación: afirma sobre cómo el consumer habla con el provider, así que se rompe si cambias la conversación aunque el resultado siga siendo correcto. Si mañana book cobra en dos llamadas de 3000 en vez de una de 6000 —mismo dinero, resultado idéntico—, calls == [6000] se pondría rojo aunque nada esté realmente mal. Por eso la sabiduría común es preferir estado cuando se pueda, y usar interacción solo cuando el resultado no baste —cobros que no deben duplicarse, correos que no deben omitirse, acciones sin estado inspeccionable—. Afirma sobre el qué (el resultado) siempre que puedas; reserva el cómo (la llamada) para cuando el qué no cuente toda la historia.
Esta distinción, dicho sea de paso, se conecta con los dobles que ya conoces de la guía hermana: un stub o un fake te sirven para contratos de estado (dan valores/estado que luego observas); un spy o un mock te sirven para contratos de interacción (registran las llamadas que luego afirmas). El tipo de doble y el tipo de contrato van de la mano: eliges el doble según lo que la cláusula necesita observar.
Errores comunes
Usar interacción donde bastaba estado (contrato frágil). Qué pasa: para verificar que book guarda bien, alguien afirma "se llamó a repo.save una vez con tal objeto" en vez de "la reserva guardada tiene tal precio". Por qué pasa: el mock está a la mano y afirmar sobre la llamada parece más directo. Cómo detectarlo: si tu test se rompería al cambiar cómo el consumer guarda (dos save en vez de uno, un objeto ligeramente distinto) aunque el resultado final sea correcto, sobre-especificaste con interacción. Cómo corregirlo: para un colaborador con estado observable (el repositorio), afirma sobre el resultado (get devuelve lo correcto). Es más robusto: sobrevive a los cambios de implementación que no cambian el resultado.
Usar estado donde hacía falta interacción (bug invisible). Qué pasa: se verifica el pago solo mirando que la reserva quedó confirmed, sin comprobar cuántas veces se llamó a charge. Por qué pasa: mirar el estado final es el reflejo por defecto. Cómo detectarlo: si un doble cobro dejaría el mismo estado final (una reserva confirmada) que un cobro único, tu contrato de estado no lo cazaría —el bug es invisible al resultado—. Cómo corregirlo: para acciones que no deben duplicarse ni omitirse, añade un contrato de interacción que afirme el número y los argumentos de las llamadas (calls == [6000]). Es la única forma de proteger el protocolo cuando el resultado no delata el error.
Afirmar sobre llamadas internas que no cruzan la costura. Qué pasa: un contrato de interacción afirma sobre métodos privados del consumer o sobre pasos internos que no son parte del trato con el provider. Por qué pasa: el mock puede registrar cualquier cosa, y es tentador verificar de más. Cómo detectarlo: si tu aserción de interacción se rompe al refactorizar el consumer sin cambiar nada de lo que el provider observa, estás afirmando sobre implementación interna, no sobre el contrato de la costura. Cómo corregirlo: un contrato de interacción afirma solo sobre la conversación entre consumer y provider —las llamadas que cruzan la costura (charge, send)—, no sobre los pasos internos del consumer. El contrato vive en la costura; lo de adentro es asunto del consumer.
Ejercicios
Ejercicio 1 — Estado o interacción. Para cada cláusula, di si es de estado o de interacción: (a) "tras cancel, la reserva guardada tiene status == 'cancelled'"; (b) "cancel llama a emails.send una vez, con el id del socio"; (c) "find_by_room('focus') devuelve dos reservas"; (d) "book de una sala ocupada no llama a payments.charge en absoluto".
Ver solución
- (a) Estado. Afirma sobre el resultado observable: qué
statusquedó en la reserva guardada. Se verifica leyendo el estado conget. - (b) Interacción. Afirma sobre la llamada al colaborador de correo: a quién (
emails.send), con qué (el id del socio), cuántas veces (una). Enviar un correo no deja estado inspeccionable en tu test; solo queda la conversación. - (c) Estado. Afirma sobre el resultado de
find_by_room: cuántas reservas devuelve. Se observa el valor de retorno. - (d) Interacción. Afirma sobre una llamada que no ocurrió: si la sala está ocupada,
bookdebe rechazar antes de cobrar, así quepayments.chargeno debe llamarse. Verificar "no se llamó" es una aserción de interacción (calls == []) —y una muy valiosa: protege de cobrar por una reserva que no se hizo—.
El patrón: si la cláusula mira lo que quedó, es estado; si mira a quién se llamó y cómo (incluido "no se llamó"), es interacción.
Ejercicio 2 — El doble cobro invisible. Un bug hace que BookingService.book llame a payments.charge dos veces con 6000 (cobra el doble), pero la reserva se guarda una sola vez, correcta. Tienes un contrato de estado del repositorio (la reserva quedó con price_cents == 6000) y todo pasa en verde. Explica por qué el contrato de estado no caza el doble cobro y escribe la cláusula que sí lo cazaría.
Ver solución
El contrato de estado no lo caza porque el doble cobro no deja rastro en el estado que ese contrato observa. La reserva guardada tiene price_cents == 6000 —lo correcto— sin importar que a charge se le haya llamado una o dos veces: el segundo cobro mueve dinero afuera (en el gateway/banco), no cambia la reserva persistida. El contrato de estado mira "la reserva guardada" y ahí todo está bien; el bug vive en la conversación con el gateway, un lugar donde el contrato de estado no mira.
La cláusula que lo caza es de interacción, con un mock que registre las llamadas:
def test_book_charges_exactly_once():
payments = MockPaymentGateway()
service = BookingService(Calendar(), FixedClock(NOW),
payments, SpyEmailSender(),
SqliteBookingRepository(sqlite3.connect(":memory:")))
service.book(FOCUS, ANA, START, END)
assert payments.calls == [6000] # UNA sola llamada, de 6000
Con el bug, payments.calls sería [6000, 6000], y la aserción == [6000] fallaría —cazando el doble cobro—. Esta es la razón exacta de existir del contrato de interacción: proteger propiedades del protocolo (cuántas veces, con qué) que el resultado final no delata. Cobrar una vez es tan importante como cobrar el monto correcto, y solo la interacción lo verifica.
Ejercicio 3 — El test frágil de más. Un compañero, para "ser exhaustivo", verifica que book guarda la reserva con este contrato de interacción: assert repo_mock.save_calls == [booking] —afirma que a save se le llamó una vez con ese objeto exacto—. Funciona hoy. Luego alguien optimiza book para guardar en dos pasos (un save provisional y otro final con el estado confirmado), sin cambiar la reserva que queda al final. El test se rompe. ¿Quién tiene razón, y qué contrato debió escribirse?
Ver solución
Tiene razón quien optimizó book, y el test de interacción estaba sobre-especificado. El comportamiento que de verdad le importa al consumer del repositorio es el resultado: que la reserva quede guardada, una sola, con los datos correctos. Cómo book llega a ese resultado —en un save o en dos— es asunto interno de book, no parte del contrato de la costura repositorio. Al afirmar save_calls == [booking], el compañero convirtió un detalle de implementación (el número de save) en una cláusula, y por eso una optimización legítima —mismo resultado final— rompió el test sin que nada esté realmente mal. Eso es un contrato frágil: se rompe por cambios que no cambian lo observable.
El contrato que debió escribirse es de estado: no importa cuántas veces se llamó a save; importa que al final get(booking.id) devuelva la reserva correcta, una sola, con status == "confirmed" y price_cents == 6000. Esa cláusula sobrevive a la optimización porque solo mira el resultado. La lección: para el repositorio —un colaborador con estado observable— prefiere estado; reserva la interacción para lo que el estado no puede contar (el doble cobro del ejercicio anterior). Interacción donde hace falta, no donde sobra.
Resumen y siguiente paso
En esta lección aprendiste que una cláusula de contrato puede escribirse de dos maneras, y a elegir la correcta. El contrato de estado afirma sobre el resultado observable —la reserva guardada tiene price_cents == 6000— y es el natural para colaboradores que dejan estado que el consumer lee de vuelta, como el repositorio; es robusto frente a los detalles de implementación. El contrato de interacción afirma sobre la llamada —a charge se le invocó una vez, con 6000— y es el natural para colaboradores que ejecutan una acción con efecto afuera, como el gateway de pago; caza bugs de protocolo (el doble cobro) que ningún estado delata, a cambio de ser más frágil frente a cambios de implementación. Con la foto del resultado contra la grabación de la conversación fijaste la imagen, y con dos verdes lado a lado viste ambos estilos verificar el mismo book de formas distintas. La regla que te llevas: prefiere estado cuando puedas, usa interacción cuando el resultado no baste.
Antes de avanzar deberías poder: clasificar una cláusula como de estado o de interacción; explicar por qué el contrato de estado no caza un doble cobro y cuándo hace falta interacción; y reconocer un contrato de interacción sobre-especificado (frágil) que debió ser de estado.
Con esto tienes el vocabulario completo del contrato hecho a mano: qué comportamiento, de quién, verificado con qué batería, afirmado sobre el resultado o la llamada. La lección 7 da el último paso del módulo: mostrar cómo la industria lleva todo esto —contratos consumer-driven, de estado y de interacción— a la comunicación entre servicios por la red, con una herramienta llamada Pact. Verás el concepto (el pact file, el broker, la verificación del provider) como la versión automatizada y en red de la batería que aquí construiste a mano —sin instalar nada—.
Recursos
- Documentación de pytest — Cómo escribir y afirmar en tests — la base para las aserciones de ambos estilos: sobre valores de retorno y estado (contrato de estado) y sobre listas de llamadas registradas (contrato de interacción).
- Martin Fowler — Mocks Aren't Stubs — la distinción clásica entre verificación basada en estado y basada en interacción (y entre stubs/fakes y mocks/spies); el marco conceptual exacto de esta lección.
- docs.pact.io — Interacciones y estados en un contrato — cómo Pact combina expectativas de interacción (la petición esperada) con estados del provider; anticipa la lección 7, donde ambos estilos reaparecen entre servicios.
test-doubles-and-test-data-guide— la guía hermana con los dobles que alimentan cada estilo: stubs/fakes para contratos de estado, spies/mocks para contratos de interacción.