Módulo 6: Puertas de calidad — cobertura y umbrales que rompen el build

4. Fallar en una caída de cobertura

Descripción

La puerta de piso fijo que montaste en la lección 3 —--cov-fail-under=80— protege de una cosa muy concreta: que la cobertura caiga por debajo de 80%. Y lo hace bien. Pero tiene un punto ciego que, si no lo conoces, te da una falsa sensación de seguridad. Imagina que la cobertura de Reservo es 91% —cómoda, bien por encima del piso—. Un compañero agrega una función nueva sin tests y la cobertura baja a 84%. ¿Qué hace la puerta de piso 80? Nada. Pasa en verde, porque 84 sigue siendo mayor que 80. La cobertura cayó siete puntos —entró código sin probar— y la puerta ni se enteró. El piso fijo vigila una línea absoluta, no el movimiento; no distingue "siempre estuvo en 84" de "acaba de caer de 91 a 84", y esas dos situaciones son muy distintas.

Al terminar esta lección vas a entender ese punto ciego y cómo cerrarlo. Vas a ver, con una demo real, la caída pasar desapercibida: la cobertura de Reservo baja de 91.03% a 83.53% al agregar un módulo sin tests, y la puerta de piso 80 pasa (exit 0), sin ver la caída de casi ocho puntos. Y vas a ver la respuesta: una puerta que falla en la caída, no solo contra un piso lejano. La forma más simple y robusta es el trinquete (ratchet): pones el umbral en donde estás hoy —91— en vez de un piso redondo y lejano, de modo que cualquier caída lo cruza. Con la puerta en 91, esa misma caída a 83.53% rompe el build (exit 1, total of 84 is less than fail-under=91). El piso deja de ser una línea lejana en el suelo y se vuelve un trinquete pegado a tu nivel actual: solo sube, nunca baja.

Conexión con el módulo: esta lección mueve una de las tres perillas de la lección 2 —el umbral— y muestra que elegirlo mal (un piso lejano) deja un hueco que elegirlo bien (un trinquete en tu nivel) cierra. Parte directa de la puerta de la lección 3: es la misma --cov-fail-under, con un número distinto y una filosofía distinta. Y alimenta la lección 6, donde el "dónde poner el umbral" se vuelve un tema de juicio completo. Aquí la pregunta es específica: ¿cómo hago que la puerta atrape una caída, no solo un piso? La respuesta —el trinquete— es una de las ideas más útiles de todo el módulo.

El velocímetro que solo mira si pasas de 120, no si frenaste de golpe

Imagina un coche con un sistema que te multa si superas los 120 km/h. Es útil: te impide ir peligrosamente rápido. Pero hay algo que ese sistema no ve. Vas por la autopista a 110, tranquilo, y de repente frenas en seco a 40 porque algo te distrajo —una frenada brusca, peligrosa para el que viene detrás—. El sistema no dice nada: 40 no supera 120, así que para él todo está bien. Vigila un techo absoluto, no los cambios bruscos. Una caída peligrosa de velocidad le es invisible, porque solo sabe comparar tu velocidad contra una línea fija de 120.

Un piso fijo de cobertura es ese sistema. --cov-fail-under=80 vigila una línea absoluta —80—: te frena si bajas de ahí. Pero una caída que no cruza esa línea le es invisible. Cobertura en 91, cae a 84: como 84 > 80, el piso no dice nada, igual que el velocímetro calla ante la frenada de 110 a 40. El problema no es que el piso esté mal; es que mide lo equivocado para detectar una caída. Un piso mide "¿estás por debajo de la línea?"; una caída es "¿bajaste respecto a donde estabas?". Son preguntas distintas, y la segunda es la que atrapa el código sin probar que se cuela mientras aún estás cómodamente por encima del piso.

La solución es cambiar la pregunta. En vez de comparar contra una línea lejana y fija, compara contra donde estabas: pon el umbral en tu nivel actual. Si estás en 91, el umbral es 91. Ahora cualquier caída —a 90, a 84— cruza el umbral y dispara la puerta, porque el umbral está pegado a tu nivel, no ocho puntos más abajo. Y cuando mejoras y subes a 93, subes el umbral a 93. Es un trinquete: una rueda dentada que solo gira en un sentido, que sube pero nunca baja. Tu cobertura puede subir libremente, pero no puede retroceder sin romper el build.

Un piso fijo vigila una línea absoluta ("¿bajaste de 80?") y es ciego a una caída que no la cruza (de 91 a 84). Un trinquete vigila el movimiento ("¿bajaste respecto a donde estabas?"): pone el umbral en tu nivel actual, así cualquier caída lo dispara. La cobertura puede subir, pero no retroceder.

La demo: la caída que el piso no ve

Vamos a reproducir el punto ciego de verdad. Partimos del estado verde de la lección 3: la suite completa de Reservo, con cancel_with_refund probado, cobertura 91.03%, puerta de piso 80 en verde. Todo cómodo.

Ahora entra código nuevo. Reservo agrega un módulo de notificaciones —los textos de los correos que se mandan cuando una reserva se confirma o se cancela—. Es código legítimo y sencillo, pero, como pasó antes con la cancelación, llega sin tests:

# reservo/notifications.py
from reservo.models import Booking


def booking_confirmed_message(booking: Booking) -> str:
    """Cuerpo del correo cuando una reserva se confirma."""
    return (
        f"Your booking {booking.id} for {booking.room.name} is confirmed. "
        f"Total: {booking.price_cents} cents."
    )


def booking_cancelled_message(booking: Booking, refund_cents: int) -> str:
    """Cuerpo del correo cuando una reserva se cancela."""
    if refund_cents > 0:
        return (
            f"Your booking {booking.id} was cancelled. "
            f"You will be refunded {refund_cents} cents."
        )
    return (
        f"Your booking {booking.id} was cancelled. "
        f"No refund applies for this cancellation."
    )

Siete líneas de código, cero tests. Corramos la puerta de piso 80 —la misma que estaba en verde— para ver qué dice ahora, de verdad, en Python 3.14.0:

python -m pytest --cov=reservo --cov-report=term-missing --cov-fail-under=80

Qué esperar (salida real, medida ejecutando):

================================ tests coverage ================================
_______________ coverage: platform darwin, python 3.14.0-final-0 _______________

Name                       Stmts   Miss  Cover   Missing
--------------------------------------------------------
reservo/__init__.py            0      0   100%
reservo/calendar.py           30      4    87%   26, 34, 50, 55
reservo/cancellations.py      17      0   100%
reservo/models.py             12      2    83%   34-35
reservo/notifications.py       7      7     0%   3-21
reservo/pricing.py            10      1    90%   14
reservo/refunds.py             9      0   100%
--------------------------------------------------------
TOTAL                         85     14    84%
Required test coverage of 80% reached. Total coverage: 83.53%
============================== 13 passed in 0.03s ==============================

Lee la tabla. La fila nueva, reservo/notifications.py 7 7 0% 3-21: siete líneas, todas sin cubrir, 0% —el código sin tests que acaba de entrar—. Y el total: TOTAL 85 14 83.53%. La cobertura cayó de 91.03% a 83.53% —casi ocho puntos— porque entró código sin probar. Pero mira la última línea de la puerta: Required test coverage of 80% reached. Total coverage: 83.53%. La puerta pasó. Confirmemos con el exit code:

python -m pytest --cov=reservo --cov-fail-under=80 > /dev/null 2>&1; echo "exit code: $?"
exit code: 0

Exit code 0. Verde. El build pasaría, el merge entraría, y nadie se enteraría de que la cobertura cayó casi ocho puntos y que hay un módulo entero sin una sola prueba. Ese es el punto ciego del piso fijo: 83.53% sigue siendo mayor que 80, así que para la puerta "todo está bien", igual que el velocímetro calla ante la frenada de 110 a 40. El código sin probar se coló por encima del piso, en el margen entre 80 y 91 que la puerta no vigila. Push a push, así es como una cobertura sana se erosiona sin que ningún build se ponga rojo.

Cerrar el hueco: el trinquete en tu nivel actual

La cura es cambiar la pregunta de la puerta. En vez de "¿estás por debajo de 80?" —una línea lejana—, preguntar "¿bajaste de donde estabas?" —91—. La forma más simple de hacerlo con las herramientas que ya tienes es poner el umbral en tu nivel actual: un trinquete. Si la cobertura es 91.03%, pones la puerta en 91:

python -m pytest --cov=reservo --cov-fail-under=91

Ahora corramos, con el módulo de notificaciones sin tests todavía adentro (cobertura 83.53%):

Qué esperar (salida real):

================================ tests coverage ================================
...
TOTAL                         85     14    84%
ERROR: Coverage failure: total of 84 is less than fail-under=91
FAIL Required test coverage of 91% not reached. Total coverage: 83.53%

Y el exit code:

python -m pytest --cov=reservo --cov-fail-under=91 > /dev/null 2>&1; echo "exit code: $?"
exit code: 1

Exit code 1. Rojo. La misma caída que el piso 80 dejó pasar, el trinquete en 91 la atrapa: total of 84 is less than fail-under=91. La lógica es simple y poderosa: si el umbral está pegado a tu nivel actual, cualquier retroceso lo cruza. No importa que sigas en 83.53% —muy por encima de un piso de 80—; importa que bajaste de 91, y el trinquete vigila exactamente eso. El código sin probar ya no tiene margen donde colarse: el margen entre 80 y 91 que el piso ignoraba, el trinquete lo cubre.

¿Y cómo se apaga esta alarma? Igual que en la lección 3: escribiendo los tests que faltan para notifications.py, subiendo la cobertura de vuelta a 91 o más, y viendo el trinquete pasar. Y cuando la nueva cobertura sea, digamos, 93%, subes el umbral a 93 —giras el trinquete un diente más—, de modo que a partir de ahí ni siquiera puedes retroceder a 91. La cobertura solo sube; el trinquete la sostiene. Ese es el ciclo sano: mejoras, subes el umbral, y el nuevo nivel queda protegido contra la próxima erosión.

El trinquete a mano y el trinquete automático

Poner el umbral en 91 "a mano" es el trinquete en su forma más simple, y para muchos proyectos es suficiente. Pero tiene un costo: alguien tiene que acordarse de subir el número cada vez que la cobertura mejora, editando el workflow. Si te olvidas, el trinquete se queda atrás —en 91 mientras la cobertura real es 95—, y vuelves a tener un margen (91–95) donde una caída pasa desapercibida. Es un trinquete que funciona, pero que hay que apretar a mano.

Existen formas de automatizarlo, y conviene que sepas que existen aunque no las montemos aquí:

  • Comparar contra la base del pull request. Herramientas de cobertura como las que se integran con GitHub (por ejemplo, servicios de reporte de cobertura) calculan la cobertura de tu rama y la comparan automáticamente contra la de la rama principal, y marcan el check en rojo si tu cambio la baja —sin que tú fijes ningún número—. Es el trinquete de verdad: el umbral es siempre "la cobertura de main", y se actualiza solo cuando main mejora. La caída se mide contra la base, no contra un piso escrito a mano.
  • Guardar el número y compararlo en el CI. Sin un servicio externo, se puede guardar la cobertura de main en un archivo (un "badge" o un coverage.json), y en cada PR comparar la nueva contra la guardada, fallando si bajó. Es el mismo trinquete, armado con tus propias piezas.

La honestidad de la guía aplica aquí: estas automatizaciones viven en el workflow de CI —que aquí no ejecutamos, no hay runner— y en servicios externos. Lo que sí ejecutamos de verdad, y lo que te llevas como técnica portable, es la idea del trinquete y su forma manual: --cov-fail-under=<tu nivel actual>, que rompe el build en cualquier caída. Empieza por ahí —es una línea, no depende de nada externo, y ya cierra el punto ciego del piso lejano—; gradúate al trinquete automático cuando el proyecto crezca y olvidarse de subir el número a mano se vuelva un riesgo real.

Piso y trinquete no son enemigos

Una aclaración para que no quedes con la idea de que el piso fijo "está mal": no lo está. Piso y trinquete responden preguntas distintas y a menudo conviven:

  • El piso fijo (--cov-fail-under=80) responde "¿la cobertura es al menos aceptable?". Es una línea de dignidad mínima: por debajo de 80, el proyecto está en problemas sin importar la historia. Útil como red de última instancia y como número estable que no hay que tocar seguido.
  • El trinquete (--cov-fail-under=<nivel actual>) responde "¿este cambio empeoró las cosas?". Es una defensa contra la erosión: aunque estés cómodo por encima del piso, no puedes retroceder.

Muchos equipos maduros usan los dos: un piso fijo bajo y estable (nunca por debajo de X) más un trinquete que impide caídas desde el nivel actual. Pero si tuvieras que elegir uno para empezar, el trinquete atrapa más problemas reales, porque la mayoría de la erosión ocurre por encima del piso —código sin probar que se cuela mientras la cobertura sigue "aceptable"—. El piso te protege del desastre; el trinquete, del descuido diario, que es mucho más frecuente. La lección 6 retoma esta decisión —qué umbral, de qué tipo, merece un proyecto— como un tema de juicio completo.

Errores comunes

Creer que un piso fijo protege de las caídas. Qué pasa: el equipo pone --cov-fail-under=80, ve la cobertura en 91%, y asume que está a salvo de que baje. Meses después la cobertura está en 82% —erosionada push a push— y ningún build se puso rojo jamás. Por qué pasa: un piso en verde da la sensación de "protegido", pero solo protege del cruce de la línea, no del deslizamiento hacia ella. Cómo detectarlo: compara la cobertura de hoy con la de hace tres meses; si bajó sin que ningún build fallara, el piso fue ciego a la caída. Cómo corregirlo: agrega un trinquete —el umbral en tu nivel actual— que falle en cualquier retroceso, no solo bajo el piso. El piso vigila el techo del peligro; el trinquete, el suelo de tu progreso.

Bajar el trinquete cuando molesta, en vez de escribir el test. Qué pasa: el trinquete en 91 rompe el build porque entró código sin probar (83.53%), y alguien "arregla" el rojo bajando el umbral a 83. El build pasa, pero acabas de convertir la caída en el nuevo normal. Por qué pasa: bajar el número es más rápido que escribir el test, y "total, sigue por encima de 80". Cómo detectarlo: si el --cov-fail-under baja en el historial, alguien aflojó el trinquete —que por definición solo debería subir—. Cómo corregirlo: un trinquete que baja no es un trinquete, es un piso móvil que legitima cada erosión. La regla dura: el umbral del trinquete solo sube. Cuando molesta, la respuesta es el test que falta (subir la cobertura de vuelta), no el umbral que sobra.

Poner el trinquete y olvidarse de subirlo al mejorar. Qué pasa: la cobertura mejora de 91 a 96 con el tiempo, pero el trinquete sigue en 91 porque nadie lo subió. Ahora hay un margen de cinco puntos (91–96) donde una caída vuelve a pasar desapercibida. Por qué pasa: subir el umbral a mano es un paso fácil de olvidar, sobre todo cuando la cobertura mejora poco a poco. Cómo detectarlo: si tu cobertura real está varios puntos por encima de tu --cov-fail-under, el trinquete se quedó atrás y reapareció el punto ciego. Cómo corregirlo: sube el umbral cada vez que la cobertura suba de forma estable —o automatiza el trinquete (comparar contra la base) para que se ajuste solo—. Un trinquete que no se aprieta se afloja con el tiempo.

Ejercicios

Ejercicio 1 — Predice qué puerta atrapa la caída. La cobertura de Reservo estaba en 91.03%. Entra código sin tests y baja a 83.53%. Para cada puerta, di si atrapa la caída (rompe el build) o la deja pasar, y por qué: (a) --cov-fail-under=80. (b) --cov-fail-under=91. (c) --cov-fail-under=85. (d) sin --cov-fail-under (solo --cov=reservo).

Ver solución
  • (a) --cov-fail-under=80: deja pasar. 83.53% ≥ 80, así que la puerta pasa (exit 0). Es el punto ciego de la lección: la caída ocurrió por encima del piso, invisible.
  • (b) --cov-fail-under=91: atrapa. 83.53% < 91, así que rompe el build (exit 1, total of 84 is less than fail-under=91). El trinquete en el nivel de partida ve cualquier retroceso.
  • (c) --cov-fail-under=85: atrapa. 83.53% < 85, así que rompe el build. Un trinquete no tiene que estar exactamente en el nivel de partida para atrapar la caída; basta con que esté por encima del nivel al que se cayó. Un umbral de 85, aunque un poco por debajo del 91 real, sigue atrapando esta caída a 83.53%.
  • (d) sin --cov-fail-under: deja pasar. Sin umbral no hay puerta; solo reporta 83.53% y termina en exit 0. La caída ni se evalúa.

La lección: cuanto más pegado esté el umbral a tu nivel real, más fina es la caída que atrapa. Un piso de 80 solo ve caídas que llegan por debajo de 80; un trinquete en 91 ve cualquier caída desde 91. (c) muestra que hay un rango intermedio: cualquier umbral por encima del 83.53% al que se cayó atrapa esta caída, pero solo un umbral en 91 o muy cerca atrapa toda caída desde el nivel actual.

Ejercicio 2 — Diseña el ciclo del trinquete. Tu proyecto está en 91.03% con el trinquete en 91. Escribes los tests que faltaban para notifications.py y la cobertura sube a 95%. Describe, paso a paso, qué haces con el umbral y por qué, y qué pasaría si te olvidas de hacerlo.

Ver solución

El ciclo sano del trinquete, paso a paso:

  1. Escribes los tests de notifications.py. La cobertura sube de 83.53% de vuelta a 91 y, con los tests nuevos que además cubren código antes descubierto, a 95%. El trinquete en 91 ahora pasa (95 ≥ 91).
  2. Subes el umbral a 95. Cambias --cov-fail-under=91 a --cov-fail-under=95 en el workflow. Giras el trinquete un diente: tu nuevo nivel queda protegido.
  3. Confirmas que sigue verde. 95% ≥ 95, la puerta pasa. Y a partir de ahora, cualquier caída por debajo de 95 —aunque sea a 94— rompe el build.

Por qué subir el umbral: si lo dejas en 91 mientras la cobertura real es 95, reaparece un margen (91–95) donde una caída futura pasaría desapercibida —volviste a tener un punto ciego, más pequeño pero real—. Subir el trinquete al nuevo nivel cierra ese margen.

Qué pasa si te olvidas: el trinquete se queda en 91, y mañana alguien puede agregar código sin tests que baje la cobertura de 95 a 92 sin romper el build (92 ≥ 91). Erosionaste cuatro puntos gratis. Por eso el paso 2 no es opcional: un trinquete solo protege el nivel al que está apretado, y hay que apretarlo cada vez que mejoras (o automatizarlo comparando contra la base, para que se ajuste solo).

Ejercicio 3 — Piso, trinquete o los dos. Para cada proyecto, recomienda una estrategia de umbral —piso fijo, trinquete, o ambos— y justifícala en dos o tres frases. (a) Una librería madura, cobertura estable en 96%, equipo grande, muchos PRs al día. (b) Un proyecto nuevo, cobertura 45%, que quiere mejorar sin bloquearse. (c) Reservo hoy: 91%, equipo chico, quiere no retroceder.

Ver solución
  • (a) Librería madura (96%, equipo grande): trinquete automático, idealmente contra la base. Con muchos PRs al día y cobertura alta, el riesgo es la erosión por descuido —un PR entre muchos que se cuela con código sin probar—. Un trinquete que compare contra la base de main y falle en cualquier caída es lo que protege ese 96% sin depender de que alguien vigile cada PR. Un piso fijo bajo (digamos 90) como red de última instancia no estorba, pero el trinquete es el que hace el trabajo.
  • (b) Proyecto nuevo (45%, quiere mejorar): trinquete suave, empezando donde está. Un piso alto (80) bloquearía todo trabajo desde el día uno —está en 45, no puede cumplir 80 sin parar a escribir tests de todo el código viejo—. La estrategia sana es un trinquete en 45 ("no empeores") que sube cada vez que mejoran, de modo que la cobertura solo puede crecer, sin exigir un salto imposible de golpe. El trinquete convierte "llegar a 80" en un camino gradual en vez de un muro.
  • (c) Reservo (91%, equipo chico, no retroceder): trinquete manual en 91. Es exactamente el caso de la lección. Un equipo chico puede manejar subir el umbral a mano cuando mejora, sin necesitar automatización todavía. --cov-fail-under=91 cierra el punto ciego del piso lejano con una sola línea; un piso fijo de 80 dejaría pasar justo las caídas que a Reservo le importan. Si el equipo crece, graduarse al trinquete automático.

La regla que atraviesa los tres: el umbral debe estar cerca de donde estás para atrapar caídas, y debe solo subir. Un piso lejano y fijo sirve de red de última instancia, pero no protege de la erosión diaria; para eso está el trinquete.

Resumen y siguiente paso

En esta lección descubriste el punto ciego del piso fijo y cómo cerrarlo. Un piso como --cov-fail-under=80 vigila una línea absoluta y es ciego a una caída que no la cruza: lo viste de verdad, cuando Reservo agregó notifications.py sin tests, la cobertura cayó de 91.03% a 83.53% —casi ocho puntos, un módulo entero sin probar— y la puerta de piso 80 pasó en verde (exit 0), como el velocímetro que calla ante una frenada brusca porque solo vigila el techo de 120.

La cura es el trinquete: poner el umbral en tu nivel actual (91) en vez de un piso lejano, de modo que cualquier retroceso lo cruce. La misma caída que el piso ignoró, el trinquete en 91 la atrapó —exit 1, total of 84 is less than fail-under=91—. El trinquete solo sube: cuando mejoras, subes el umbral; cuando entra código sin probar, rompe el build. Viste su forma manual (una línea, portable, sin dependencias) y sus formas automáticas (comparar contra la base del PR), y que piso y trinquete no son enemigos —el piso es la red de última instancia, el trinquete la defensa contra la erosión diaria—.

Antes de avanzar deberías poder: explicar por qué un piso fijo no atrapa una caída que se queda por encima de él; poner un trinquete con --cov-fail-under=<nivel actual> y decir por qué atrapa cualquier retroceso; describir el ciclo sano (mejoras → subes el umbral) y por qué el trinquete solo sube; y decidir entre piso, trinquete o ambos según el proyecto.

Lo que sigue, en la lección 5, es cambiar de métrica. Hasta aquí toda puerta miró la cobertura; ahora vas a montar una puerta que mira un subconjunto de tests: la puerta por marcador. Un job de CI que corre pytest -m smoke y exige que ese grupo crítico de tests pase antes de permitir un merge —rápido, barato, y una primera línea de defensa distinta de la cobertura—. Verás que es la misma receta de la lección 2 (métrica, umbral, consecuencia) con la métrica cambiada de "% cubierto" a "¿pasaron los smoke?".

Recursos

  • pytest-cov: --cov-fail-under — la bandera que usaste como piso y como trinquete; el número que le pasas es toda la diferencia entre las dos estrategias. Repasa cómo se combina con --cov.
  • Coverage.py: fail_under y precisión decimal — cómo se define el umbral y por qué a veces conviene fijar decimales (precision) para que un trinquete no falle por un redondeo. Útil cuando aprietas el trinquete al nivel exacto.
  • Acerca del estado de checks y la comparación en pull requests — GitHub — cómo un check de CI se convierte en condición de merge; es la base sobre la que un trinquete automático (comparar contra main) se apoya para bloquear una caída.
  • Coverage.py: comando report — el reporte que leíste para ver la fila de notifications.py en 0% y el total cayendo. La columna Missing es tu mapa de qué probar para volver a apretar el trinquete.