Módulo 8: Proyecto: contrato + integración de Reservo

7. Cazar un breaking change con el contrato

Descripción

Tienes los tres entregables completos: el contrato desde ambos lados y la integración de punta a punta aislada. Esta lección no añade un cuarto entregable —cobra el valor de los tres—. Todo lo que construiste 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. El escenario que verás es el más común y el más peligroso, porque el cambio parece inofensivo: no es un error obvio, es una "simplificación" razonable que un compañero haría con buena fe, que compila, y que pasa todos los tests que no ejercitan justo ese caso.

El cambio es el que la guía anticipó desde el módulo 2: SqliteBookingRepository.get, en vez de lanzar cuando el id no existe, empieza a devolver None. Una línea. Para quien la 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. Vas a ver el mismo bug en sus dos destinos: el crimen, lo que pasa en producción sin contrato —un AttributeError lejano, confuso y tardío—, y el arresto, el rojo quirúrgico del contrato que lo caza en tu máquina, en dos centésimas de segundo, antes de fusionar. La diferencia entre esos dos destinos es, en una frase, por qué existe el contract testing.

Conexión con el módulo: esta lección es la demostración de que la entrega no es burocracia, sino una red de seguridad activa del deploy. El contrato que escribiste en la lección 2 y verificaste en las lecciones 3 y 4 —verde por ambos lados— es el que aquí se pone rojo en cuanto alguien rompe una promesa. Es el pago de oro de todo el proceso: el que convierte "escribí un contrato" en "mi contrato me acaba de ahorrar un incidente en producción". La lección 8 recoge formalmente la entrega y cierra la guía; esta le da su razón de ser.

Analogía: el detector de humo

Una casa puede tener una instalación eléctrica impecable y aun así incendiarse: un cable que se pela con los años, un electrodoméstico que falla, una vela olvidada. No puedes evitar que nunca haya un principio de fuego —eso es parte de vivir en una casa—. Lo que decides es cuándo te enteras. Sin detector de humo, te enteras tarde y lejos: cuando el fuego ya avanzó, el humo llenó los cuartos, y el daño es grande y caro. Con un detector de humo, te enteras temprano y cerca: un pitido en la cocina cuando el fuego apenas empieza, con tiempo de sobra para apagarlo con un vaso de agua. El detector no impide el fuego; impide que el fuego crezca sin ser visto.

El contrato es el detector de humo del deploy. No impide que un compañero cometa un breaking change —eso es humano e inevitable, como el cable que se pela—. Lo que hace es cambiar cuándo te enteras. Sin contrato, te enteras en producción: un AttributeError a las tres de la mañana, lejos de la causa, con usuarios afectados —el incendio avanzado—. Con contrato, te enteras al correr la batería antes de fusionar: un rojo con nombre y apellido en tu máquina, con el fuego apenas empezando —el pitido en la cocina—. El bug es el mismo en los dos casos; lo único que cambia es si lo apagas con un vaso de agua o con los bomberos. Esta lección es ver el incendio con y sin detector.

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 honraba el contrato:

    def get(self, booking_id):
        row = self._conn.execute(...).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(...).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 confía en que get lanza para un id ausente; con el provider cambiado, get no lanza: devuelve None, y cancel sigue adelante con booking = None. Corramos ese escenario tal cual ocurriría:

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

python3 demo_prod_crime.py     # cancel de un id ausente, con el provider ya cambiado
Traceback (most recent call last):
  File "demo_prod_crime.py", line 17, in <module>
    service.cancel("does-not-exist")
  File "reservo/services.py", line 36, 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 —dos 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, 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. Es el incendio descubierto cuando ya llenó de humo la casa.

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 tu entregable —sin tocar una línea de los tests—; lo único que cambió es la implementación del provider. Corramos la batería con el provider ya cambiado:

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

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:34: Failed
=========================== short test summary info ============================
FAILED tests/test_repository_contract.py::test_get_of_a_missing_id_raises[sqlite] - Failed: DID NOT RAISE KeyError
========================= 1 failed, 7 passed in 0.04s ==========================

1 failed, 7 passed. Compara este rojo con el AttributeError de producción: son de otro planeta. Aquí el mensaje es quirúrgico y te dice tres cosas. 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. El pitido en la cocina sonó con el fuego apenas empezando.

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ó. El id entre corchetes, que en la lección 3 parecía un detalle, es aquí la mitad del diagnóstico.

Por qué el contrato ve lo que la suite de unit tests no

Podrías preguntarte: ¿por qué la suite de unit tests del equipo no cazó esto? La respuesta cierra el círculo de toda la guía. 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. Es la brecha del módulo 1, otra vez, ahora en el momento de un cambio.

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 nombrar, porque es el porqué de todo el capstone: 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; hace que se encuentren temprano, cuando arreglarlos es trivial. Esa es, en una frase, la razón de ser de los tres entregables que armaste: 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 revertir el cambio del provider (que vuelva a lanzar) o, si el equipo decide deliberadamente que get debe devolver None, renegociar el contrato con el consumer —cambiar la cláusula y adaptar a BookingService.cancel a la vez—. Nunca aflojar el test en silencio: eso reintroduce el bug y apaga la alarma.

Creer 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 volver a correr el contrato tras un cambio del provider. Qué pasa: alguien edita SqliteBookingRepository y no corre la batería, confiando en que "ya estaba verde". Por qué pasa: el contrato pasó ayer, así que parece innecesario re-correrlo. Cómo detectarlo: si tu flujo no corre la batería ante cada cambio del provider, un breaking change se cuela igual que un detector de humo apagado no avisa del fuego nuevo. 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. Un detector que nadie enciende no sirve de nada.

Ejercicios

Ejercicio 1 — Lee los dos errores. Pon lado a lado el AttributeError de producción y el Failed: DID NOT RAISE KeyError del contrato. Para cada uno, di qué te dice sobre la causa, cuán lejos está el síntoma de la causa, y en qué momento del ciclo de vida del cambio apareció.

Ver solución

El AttributeError: 'NoneType' object has no attribute 'price_cents' (producción):

  • Qué te dice sobre la causa: casi nada. Nombra price_cents y NoneType, pero no menciona a get, ni al id ausente, ni al breaking change. La causa —el get que devolvió None— no aparece en el mensaje.
  • Cuán lejos está el síntoma de la causa: lejos. El error explota en refund_cents, dos pasos después de la línea (get) donde de verdad se rompió el contrato. Hay que rastrear cuesta arriba para encontrarla.
  • Cuándo apareció: en producción, en el extremo derecho del ciclo. Tarde y caro: con usuarios afectados y un incidente en curso.

El Failed: DID NOT RAISE KeyError (contrato):

  • Qué te dice sobre la causa: casi todo. La cláusula (test_get_of_a_missing_id_raises) nombra el comportamiento roto, el corchete ([sqlite]) nombra al culpable, y el mensaje (DID NOT RAISE) nombra el síntoma exacto.
  • Cuán lejos está el síntoma de la causa: pegados. El rojo apunta directo a la cláusula y al provider que la violó; no hay nada que rastrear.
  • Cuándo apareció: al correr la batería local, en el extremo izquierdo del ciclo. Temprano y barato: antes de fusionar, en dos centésimas de segundo.

Es el mismo bug, descubierto en dos puntos del tiempo. El contrato no lo evitó; lo movió del extremo caro (producción) al barato (tu máquina). Esa es toda la propuesta de valor del contract testing.

Ejercicio 2 — Otro breaking change, otra cláusula. El equipo del provider ahora "optimiza" save: para ir más rápido, deja de hacer self._conn.commit(). Sin correr, predice qué cláusula(s) del contrato se pondrían rojas, en qué lado, y por qué la del [fake] seguiría verde.

Ver solución

Se pondría roja test_save_then_get_returns_the_same_booking[sqlite] (y probablemente también test_saving_the_same_id_twice_updates_not_duplicates[sqlite] y test_find_by_room_...[sqlite], todas las que guardan y luego leen). Sin commit, según cómo se lea después, el get/find_by_room puede no encontrar la fila recién guardada, así que "guardar-y-leer devuelve la misma reserva" falla: repo.get("bk-1") no encuentra nada y lanza KeyError donde el test esperaba la reserva. La cláusula que promete el ida-y-vuelta se rompe.

El lado [fake] seguiría verde porque el fake no tiene transacciones ni commit: guarda el objeto directo en un dict, así que un save seguido de get siempre encuentra la reserva. El fake no cambió y, por su naturaleza, no puede sufrir un bug de commit —no tiene commits—. 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 (quitar un commit) que "compila y pasa los unit tests del fake" pero que el contrato caza antes del deploy.

Ejercicio 3 — Cuando el cambio es intencional. Supón que el equipo decide, deliberadamente, que get debe devolver None para un id ausente (para alinearse con otra API). El contrato se pone rojo. Explica la secuencia correcta 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:

  1. Acordar el nuevo contrato con el lado del consumer. El dueño del contrato es el consumer; si get va a devolver None, hay que ver a todos los consumers que dependían de que lanzara —empezando por BookingService.cancel— y decidir cómo se adaptan.
  2. Cambiar la cláusula del contrato para reflejar el nuevo acuerdo: test_get_of_a_missing_id_returns_none en vez de ..._raises, afirmando assert repo.get("does-not-exist") is None.
  3. Adaptar a los consumers a la vez. cancel ya no puede confiar en un KeyError; ahora debe chequear if booking is None: raise SomeError(...) explícitamente antes de usar booking.price_cents. Sin este paso, el AttributeError de la parte 1 vuelve.
  4. 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 cobraste el valor de los tres entregables: cazaste 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. 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 detector de humo entendiste que el contrato no impide el breaking change, sino que impide que crezca sin ser visto —el pitido en la cocina en vez del incendio a las tres de la mañana—. 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é la suite de unit tests 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.

Ya tienes todo: los tres entregables, y la prueba de que valen la pena. Falta reunirlo en una entrega formal y cerrar la guía. En la lección 8 escribes el enunciado del proyecto —los tres entregables, la rúbrica que evalúa el método y no la cantidad, y la solución de referencia completa con su salida real—, y cerramos el recorrido: un repaso de los ocho módulos y el mapa de a dónde seguir en el ecosistema de Testing.

Recursos