Módulo 4: Verificar el contrato desde ambos lados
5. Cazar un breaking change del provider
Descripción
Llegamos al pago de oro. Todo lo anterior —los dos lados, la batería contra ambos, la garantía por transitividad— apuntaba a este momento: usar el contrato para cazar un cambio incompatible antes de desplegarlo. Un breaking change (cambio incompatible) es una modificación en un componente que rompe una promesa de la que otro componente depende. Aquí el componente que cambia es el provider —el SqliteBookingRepository— y la promesa que rompe es una cláusula del contrato. El escenario es el más común y el más peligroso: el cambio parece inofensivo. No es un error obvio; es una "simplificación" razonable que un compañero haría con buena fe, y que compila, y que pasa todos los tests que no ejercitan justo ese caso.
El cambio concreto es el que anticipamos desde el módulo 2: get, en vez de lanzar cuando el id no existe, ahora devuelve None. Una línea. raise KeyError(booking_id) se convierte en return None. Para quien lo escribe, se ve más limpio —"devuelvo None si no lo encuentro, como muchas APIs"—. Pero el contrato prometía que get de un id ausente lanza, y hay un consumer real —BookingService.cancel— apoyado en esa promesa. Sin contrato, el cambio se despliega y explota en producción, lejos y tarde. Con contrato, corres la batería y el lado [sqlite] se pone rojo en la cláusula exacta, en tu máquina, en dos centésimas de segundo. Esta lección es esa cacería, con salida real de ambos momentos: el crimen (lo que pasa en producción sin contrato) y el arresto (el rojo que lo caza antes).
Conexión con el módulo: las lecciones 2, 3 y 4 construyeron la maquinaria; esta la dispara. Es la demostración de que el contrato no es documentación bonita, sino una red de seguridad activa del deploy. Su lección espejo es la 6: allí el que rompe el acuerdo no es el provider (que incumple una promesa) sino el consumer (que se apoya en algo no prometido). Juntas, las dos cubren las dos formas de romper un contrato. La 7 cerrará explicando quién es el dueño del acuerdo y por qué.
Analogía: el proveedor de tornillos que cambia la rosca
Imagina una fábrica que ensambla bicicletas. Un proveedor externo le manda tornillos de una rosca estándar —digamos, métrica M5—, y la línea de ensamblaje entera está diseñada alrededor de esa rosca: las tuercas, las herramientas, los orificios. El acuerdo con el proveedor dice "rosca M5". Un día, el proveedor decide "mejorar" sus tornillos a una rosca M6, más resistente. Para él es un avance; no avisa, porque "un tornillo sigue siendo un tornillo". El cambio es, para su catálogo, inofensivo.
Sin control de calidad en la recepción, esos tornillos M6 entran a la línea, y el desastre aparece tarde y lejos: no en la recepción, sino a mitad del ensamblaje, cuando una tuerca M5 no enrosca, o —peor— cuando enrosca a la fuerza, la bicicleta sale, y una rueda se afloja en la calle con un cliente encima. El síntoma está a kilómetros de la causa: nadie en la calle sospecha del proveedor de tornillos. Ahora pon un inspector en la recepción con un calibre y el acuerdo: cada lote de tornillos se mide contra "rosca M5" antes de entrar a la línea. El lote M6 se rechaza en la puerta, con un diagnóstico exacto —"rosca incorrecta: se esperaba M5"—, y jamás llega a una bicicleta.
El proveedor de tornillos es el equipo del provider; la rosca M5 es la cláusula del contrato; cambiar a M6 es el get que devuelve None; la bicicleta que se afloja en la calle es BookingService.cancel reventando en producción; y el inspector con el calibre en la recepción es la batería de contrato que corres antes del deploy. El contrato no impide que el proveedor cambie la rosca; impide que la rosca cambiada entre a la línea sin ser vista.
Ejemplo trabajado, parte 1: el crimen (producción sin contrato)
Veamos primero qué pasa si el cambio se despliega sin que el contrato lo cace. El equipo del provider edita SqliteBookingRepository.get. Antes:
def get(self, booking_id):
row = self._conn.execute(
f"SELECT {SELECT_COLUMNS} FROM bookings WHERE id = ?",
(booking_id,),
).fetchone()
if row is None:
raise KeyError(booking_id) # honra el contrato: id ausente -> lanza
return _row_to_booking(row)
Después del cambio "inofensivo":
def get(self, booking_id):
row = self._conn.execute(
f"SELECT {SELECT_COLUMNS} FROM bookings WHERE id = ?",
(booking_id,),
).fetchone()
if row is None:
return None # CAMBIO INCOMPATIBLE: antes lanzaba KeyError
return _row_to_booking(row)
Una sola línea distinta. Ahora, en producción, alguien pide cancelar una reserva que ya no existe (un doble clic, un id viejo, lo que sea). BookingService.cancel empieza así:
def cancel(self, booking_id) -> int:
booking = self._repo.get(booking_id) # antes lanzaba; ahora devuelve None
now = self._clock.now()
refund = refund_cents(booking, booking.price_cents, now) # <-- booking es None
...
cancel confía en que get lanza para un id ausente —lo documentamos en la lección 2—. Con el provider cambiado, get no lanza: devuelve None, cancel sigue adelante con booking = None, y revienta más abajo. Corramos ese escenario tal cual ocurriría:
Qué esperar. En mi máquina (Python 3.14.0):
python3 demo_prod.py # cancel de un id ausente, con el provider ya cambiado
Traceback (most recent call last):
File "/tmp/demo_prod.py", line 13, in <module>
service.cancel("does-not-exist")
File "/tmp/m4work/reservo/services.py", line 37, in cancel
refund = refund_cents(booking, booking.price_cents, now)
AttributeError: 'NoneType' object has no attribute 'price_cents'
Mira el error con cuidado, porque su forma es la moraleja. No dice "el repositorio rompió su contrato". No menciona a get, ni al cambio, ni al id ausente. Dice AttributeError: 'NoneType' object has no attribute 'price_cents', en la línea de refund_cents, dentro de cancel —tres pasos después del verdadero problema—. El síntoma está lejos de la causa: quien depure esto en producción verá cancel fallando en price_cents y perderá tiempo sospechando de la lógica de reembolso, del cálculo, de todo menos del get del repositorio que, tres líneas antes y en silencio, devolvió None en vez de lanzar. Ese es el costo de un breaking change que llega a producción: un error confuso, lejano y tardío, con un rastreo cuesta arriba hasta una causa que el mensaje no nombra.
Ejemplo trabajado, parte 2: el arresto (el contrato lo caza antes del deploy)
Ahora rebobinemos al momento correcto: el cambio está hecho en el provider, pero antes de fusionar y desplegar, corremos la batería de contrato. Es la misma batería de la lección 4 —sin tocar una línea de los tests—; lo único que cambió es la implementación del provider.
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] FAILED [ 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%]
=================================== FAILURES ===================================
___________________ test_get_of_a_missing_id_raises[sqlite] ____________________
repo = <reservo.sqlite_repo.SqliteBookingRepository object at 0x105ebb750>
def test_get_of_a_missing_id_raises(repo):
> with pytest.raises(KeyError):
^^^^^^^^^^^^^^^^^^^^^^^
E Failed: DID NOT RAISE KeyError
tests/test_repository_contract.py:41: Failed
=========================== short test summary info ============================
FAILED tests/test_repository_contract.py::test_get_of_a_missing_id_raises[sqlite]
========================= 1 failed, 7 passed in 0.02s ==========================
1 failed, 7 passed. Compara este rojo con el AttributeError de producción: son de otro planeta. Aquí el mensaje es quirúrgico. Primero, qué cláusula se rompió: test_get_of_a_missing_id_raises —"get de un id ausente lanza"—. Segundo, quién la rompió: el corchete [sqlite], el provider real; el [fake] de la misma cláusula sigue verde, porque el fake no cambió. Tercero, cómo: Failed: DID NOT RAISE KeyError —el provider debía lanzar y no lanzó—. Con eso sabes exactamente qué revisar (el get de SQLite), qué esperaba el contrato (un KeyError), y que ni el consumer ni el fake tienen la culpa. Y todo esto ocurrió antes de fusionar: el breaking change nunca llegó a producción, nunca reventó un cancel, nunca confundió a nadie con un AttributeError lejano.
Fíjate en la asimetría del rojo: solo el [sqlite] falló. Ese detalle es diagnóstico puro. Que [fake] esté verde y [sqlite] rojo en la misma cláusula te dice, sin ambigüedad, "el que se desvió del contrato es el provider real, no el contrato mismo ni el fake". Si ambos lados hubieran fallado, sospecharías del test o del contrato; que falle uno solo señala con el dedo a la implementación que cambió.
Por qué el contrato ve lo que un unit test no
Podrías preguntarte: ¿por qué la suite de unit tests del equipo no cazó esto? La respuesta es la brecha del módulo 1, y verla aquí cierra el círculo. Los unit tests de BookingService usan el FakeBookingRepository, y el fake no cambió: su get sigue lanzando KeyError para un id ausente. Así que los unit tests de cancel siguen viendo un get que lanza, siguen pasando, y no tienen forma de saber que el otro provider —el real, el de producción— dejó de lanzar. El breaking change vive exactamente en la divergencia entre el fake y el real, y ningún test que use solo el fake puede verlo.
El contrato ve lo que el unit test no porque el contrato corre contra el provider que cambió. La cláusula test_get_of_a_missing_id_raises[sqlite] ejercita el SqliteBookingRepository real, no el fake, así que cuando el real deja de lanzar, esa línea lo nota. Es la misma lección del módulo 1 —"cruza la costura con la pieza real"—, ahora sistematizada: no cruzas la costura una vez a mano, sino en cada corrida del contrato, para cada cláusula, automáticamente. El contrato es la red que hace que "probar contra lo real" no dependa de que alguien se acuerde de hacerlo.
Correr el riesgo hacia la izquierda
Hay una idea de fondo que vale la pena nombrar, porque es el porqué de todo el módulo: mover el descubrimiento del error hacia la izquierda en el tiempo. Dibuja la vida de un cambio de izquierda a derecha: lo escribes, lo revisas, corren los tests, se fusiona, se despliega, corre en producción. Cuanto más a la derecha descubres un bug, más caro es: en producción cuesta un incidente, usuarios afectados, un rastreo urgente; en la revisión cuesta un comentario; en tu suite local cuesta dos centésimas de segundo y un rojo claro.
El breaking change del get es el mismo bug en los dos ejemplos de esta lección; lo único que cambia es dónde se descubre. En la parte 1 se descubre en producción (extremo derecho): caro, confuso, tardío. En la parte 2 se descubre al correr la batería local (extremo izquierdo): barato, claro, inmediato. El contrato es la herramienta que empuja el descubrimiento hacia la izquierda —del incidente al test rojo—. No hace que la gente deje de cometer breaking changes (eso es humano e inevitable); hace que los breaking changes se encuentren temprano, cuando arreglarlos es trivial. Esa es, en una frase, la razón de ser del contract testing: correr el riesgo hacia la izquierda.
Errores comunes
Ver el rojo y "arreglar el test" en vez del provider. Qué pasa: alguien ve test_get_of_a_missing_id_raises[sqlite] en rojo y cambia el test para que acepte None, poniéndolo verde. Por qué pasa: un rojo se siente como un test molesto, y "hacerlo pasar" parece progreso. Cómo detectarlo: si tu arreglo del rojo consistió en debilitar la aserción del contrato en vez de tocar el provider, invertiste la relación —dejaste que la implementación mande sobre el contrato—. Cómo corregirlo: el contrato es el acuerdo; el rojo dice que el provider lo violó. La decisión correcta es o revertir el cambio del provider (que vuelva a lanzar) o, si el equipo decide deliberadamente que ahora get debe devolver None, renegociar el contrato con el consumer —cambiar la cláusula y adaptar a BookingService.cancel para el nuevo comportamiento, a la vez—. Nunca aflojar el test en silencio para tapar el rojo: eso reintroduce el bug y apaga la alarma.
Creer que un cambio "que compila y pasa los unit tests" es seguro. Qué pasa: el cambio del get compila, los unit tests de BookingService (con el fake) siguen verdes, y se despliega con confianza. Por qué pasa: "compila y los tests pasan" es el criterio habitual de seguridad. Cómo detectarlo: si los tests que pasaron usan solo el fake en la costura que cambió, no probaron el cambio —probaron el fake, que no cambió—. Cómo corregirlo: para un cambio en un provider, el criterio de seguridad no es "los unit tests pasan", sino "el contrato del provider pasa", porque el contrato es lo único que corre contra la implementación que tocaste. Añade la batería de contrato a lo que corres antes de fusionar cambios del repositorio.
No correr el contrato tras un cambio del provider. Qué pasa: alguien edita SqliteBookingRepository y no corre la batería de contrato, confiando en el resto de la suite. Por qué pasa: el contrato "ya estaba verde", así que parece innecesario re-correrlo. Cómo detectarlo: si tu flujo no corre la batería de contrato ante cada cambio del provider, un breaking change puede colarse igual que se colaría el lote de tornillos M6 sin inspector en la recepción. Cómo corregirlo: la batería solo protege si vuelve a correr. El arresto de la parte 2 ocurrió porque alguien corrió la batería después del cambio; si no la hubiera corrido, el crimen de la parte 1 habría seguido su curso. El contrato es un inspector, y un inspector que no revisa el lote nuevo no sirve de nada.
Ejercicios
Ejercicio 1 — Lee el rojo. El fallo dice test_get_of_a_missing_id_raises[sqlite] ... Failed: DID NOT RAISE KeyError. Descompón ese mensaje en las tres cosas que te dice, y explica qué información te daría —o te quitaría— si el corchete dijera [fake] en vez de [sqlite], o si ambos lados fallaran.
Ver solución
Las tres cosas que dice el mensaje:
- Qué cláusula se rompió:
test_get_of_a_missing_id_raises→ "get de un id ausente debe lanzar". Sabes exactamente qué promesa del contrato se violó. - Quién la rompió: el corchete
[sqlite]→ el provider real. El culpable es la implementación de SQLite, no el fake ni el consumer. - Cómo:
DID NOT RAISE KeyError→ el provider debía lanzarKeyErrory no lanzó nada (devolvióNone). El síntoma técnico exacto.
Si el corchete dijera [fake]: te diría que fue el fake el que dejó de lanzar —quizás alguien "sincronizó" mal el doble—. Sería el bug del módulo 2 (el fake que miente), no un breaking change del provider real. Distinta causa, distinto lugar donde mirar.
Si ambos lados fallaran ([fake] y [sqlite]): sospecharías del test o del contrato, no de una implementación —porque es raro que las dos implementaciones se rompan igual a la vez de forma independiente—. Que falle un solo lado es lo que apunta con el dedo a la implementación que cambió; que fallen los dos sugiere que la aserción misma está mal escrita o que renegociaste el contrato a medias. El corchete no es decoración: es la mitad del diagnóstico.
Ejercicio 2 — Otro breaking change, otra cláusula. El equipo del provider ahora "optimiza" find_by_room: por descuido, la consulta pierde su WHERE room_id = ? y queda como SELECT ... FROM bookings a secas. Sin correr nada, predice: ¿qué cláusula del contrato se pondría roja, en qué lado, con qué diferencia de valores, y por qué la del [fake] seguiría verde?
Ver solución
Se pondría roja test_find_by_room_returns_only_that_rooms_bookings[sqlite]. Esa cláusula guarda dos reservas —bk-1 en focus y bk-2 en studio— y espera que find_by_room("focus") devuelva solo {"bk-1"}. Sin el WHERE room_id = ?, la consulta devuelve todas las filas de la tabla, así que find_by_room("focus") regresa las dos reservas y el conjunto de ids es {"bk-1", "bk-2"}. La aserción assert ids == {"bk-1"} falla con un mensaje que muestra el elemento colado: Extra items in the left set: 'bk-2'. La promesa "devuelve solo las reservas de esa sala" se rompió porque el filtro desapareció.
El lado [fake] seguiría verde porque el FakeBookingRepository.find_by_room filtra en Python con su comprensión de lista ([b for b in self._store.values() if b.room_id == room_id]), y ese filtro no cambió. El fake sigue devolviendo solo las de focus. De nuevo la asimetría es el diagnóstico: [fake] verde + [sqlite] rojo = "el provider real rompió una promesa que el fake sigue cumpliendo". Y de nuevo, un breaking change de una sola línea (perder el WHERE) que "compila y pasa los unit tests del fake" pero que el contrato caza antes del deploy, señalando la cláusula exacta y el dato de más que se coló.
Ejercicio 3 — Cuando el cambio es intencional. Supón que el equipo decide, deliberadamente y por buenas razones, que get debe devolver None para un id ausente (para alinearse con otra API). El contrato se pone rojo. Explica cuál es la secuencia correcta de pasos para hacer ese cambio sin dejar un bug, y por qué "solo cambiar el test para que acepte None" no es esa secuencia.
Ver solución
La secuencia correcta trata el cambio como lo que es: una renegociación del contrato entre el consumer y el provider, no un ajuste unilateral del provider. Los pasos:
- Acordar el nuevo contrato con el lado del consumer. El dueño del contrato es el consumer (lección 7); si
getva a devolverNone, hay que ver a todos los consumers que dependían de que lanzara —empezando porBookingService.cancel— y decidir cómo se adaptan. - Cambiar la cláusula del contrato para reflejar el nuevo acuerdo:
test_get_of_a_missing_id_returns_noneen vez de..._raises, afirmandoassert repo.get("does-not-exist") is None. - Adaptar a los consumers a la vez.
cancelya no puede confiar en unKeyError; ahora debe chequearif booking is None: raise SomeError(...)explícitamente antes de usarbooking.price_cents. Sin este paso, elAttributeErrorde la parte 1 vuelve. - Hacer que el provider cumpla la nueva cláusula (que devuelva
None) y correr la batería: verde por ambos lados, con la promesa nueva.
Por qué "solo cambiar el test para que acepte None" no es esa secuencia: ese atajo hace el paso 2 (afloja la cláusula) pero se salta el 1 y el 3. El resultado es un contrato verde y un consumer roto: cancel sigue esperando un KeyError que ya no llega, y el AttributeError de producción reaparece —solo que ahora sin ninguna alarma, porque apagaste la única que lo detectaba—. Cambiar el contrato es legítimo; cambiarlo sin adaptar a quien dependía de la promesa vieja es cómo se introduce un bug con la bendición de una suite verde. El rojo no es el enemigo: es la lista de a quién hay que avisar.
Resumen y siguiente paso
En esta lección disparaste la maquinaria y cobraste el pago de oro: cazar un breaking change del provider antes de desplegarlo. Viste el mismo bug —el get que devuelve None en vez de lanzar— en sus dos destinos posibles. En producción, sin contrato, es un AttributeError: 'NoneType' object has no attribute 'price_cents' dentro de cancel, lejos de la causa, confuso y tardío: el crimen. Antes del deploy, con contrato, es un rojo quirúrgico —test_get_of_a_missing_id_raises[sqlite], DID NOT RAISE KeyError, con [fake] intacto— que nombra la cláusula, el provider culpable y el síntoma exacto en dos centésimas de segundo: el arresto. Con el proveedor de tornillos y su rosca cambiada entendiste que el contrato no impide que el provider cambie, sino que impide que el cambio entre a la línea sin ser visto. Y nombraste la idea de fondo: correr el riesgo hacia la izquierda, del incidente caro al test rojo barato.
Antes de avanzar deberías poder: distinguir el error de producción (lejano, confuso) del rojo del contrato (quirúrgico, inmediato); explicar por qué el unit test con el fake no cazó el cambio y el contrato sí; leer el corchete [sqlite]/[fake] como diagnóstico; y describir la secuencia correcta para un cambio intencional del provider sin dejar un bug.
Esta lección cazó al provider que rompe una promesa. Su espejo es el consumer que se apoya en una promesa que nunca se hizo. La lección 6 toma ese caso: BookingService supone que find_by_room viene ordenado, algo que el contrato no promete, y verás la técnica para cazar esa suposición de más —correr el consumer contra un provider que devuelve un orden legal distinto— con su propia salida real, roja y verde.
Recursos
- Documentación de pytest —
pytest.raisesy verificar excepciones — la referencia delwith pytest.raises(KeyError)cuyoDID NOT RAISE KeyErrores el mensaje que caza el breaking change; útil para entender exactamente qué afirma esa aserción. - docs.pact.io — Can I Deploy y la verificación del provider — la idea industrial de usar la verificación de contratos como puerta del deploy ("¿puedo desplegar sin romper a nadie?"); la versión entre servicios de correr la batería antes de fusionar.
sqlite3—Cursor.fetchone(documentación de Python) — la referencia delfetchone()que devuelveNonecuando no hay filas; el punto exacto donde el provider decide entre lanzar (contrato) o devolverNone(breaking change).- Módulo 2 de esta guía — El doble que mintió — donde apareció por primera vez la divergencia
None-contra-lanza; útil para ver que el breaking change de esta lección es esa misma divergencia, ahora cazada sistemáticamente por el contrato.