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

1. Presentación del módulo: puertas de calidad

Descripción

Hasta aquí tu pipeline hacía algo valioso pero incompleto: medía. Corría la suite de Reservo en cada push, en varias versiones de Python, rápido y cacheado, y te devolvía un veredicto honesto —verde si todos los tests pasan, rojo si alguno falla—. Eso es medir. Pero hay una clase de degradación que ningún test rojo delata, y es la que este módulo ataca. Imagina que un compañero agrega a Reservo una función nueva —digamos, la que cancela una reserva y calcula el reembolso— y, con prisa, la sube sin un solo test. ¿Qué hace tu pipeline? Nada. La suite sigue verde, porque los tests viejos siguen pasando; el código nuevo, sin tests, simplemente no se ejercita. Nadie ve un rojo, nadie se entera. La cobertura —el porcentaje del código que tus tests de verdad ejecutan— bajó, en silencio, y push tras push se sigue erosionando hasta que un día la mitad del código no está probado y nadie decidió que así fuera.

Al terminar esta lección vas a entender qué es una puerta de calidad y por qué un CI que solo mide no basta. Una puerta de calidad es un umbral que convierte una métrica en una condición de merge: no se limita a reportar "la cobertura es 65%", sino que declara "si la cobertura baja de 80%, rompo el build". Y cuando el build se rompe —exit code distinto de cero—, el cambio no entra a la rama principal hasta que alguien lo arregle. Vas a ver, ejecutada de verdad en tu máquina, la puerta más común: pytest --cov=reservo --cov-fail-under=80, rompiendo el build con exit code 1 cuando la cobertura de Reservo está por debajo de 80, y volviéndose verde cuando agregas el test que faltaba. Ese es el salto de este módulo: de un pipeline que te dice cómo está la calidad a uno que la exige.

Conexión con el módulo: esta lección es el mapa. Aquí instalas la idea —la diferencia entre medir e imponer— y la ves latir una vez con una demo local real. La lección 2 desarma el concepto de puerta pieza por pieza: métrica, umbral y consecuencia, y por qué el exit code es el idioma en que una puerta le habla al CI. La 3 es el corazón: --cov-fail-under ejecutado de verdad, con su exit code, y su primo el CLI de coverage. La 4 muestra el punto ciego del piso fijo y cómo una puerta puede fallar en una caída de cobertura, no solo contra un piso lejano. La 5 sale de la cobertura y monta una puerta por marcador: el job que exige que los tests smoke pasen antes de mergear. La 6 es el juicio —cuándo una puerta ayuda y cuándo estorba—. La 7 es el lado oscuro: el 100% como fetiche que empuja a tests tautológicos, el "verde que no verifica" convertido en política. Y la 8, el mini-proyecto, te pone a montar la puerta de cobertura del CI de Reservo de principio a fin.

Una nota sobre la frontera, porque este módulo se apoya en una guía hermana y no la repite. La cobertura como herramienta local —qué es, cómo se lee un reporte, qué significa una línea sin cubrir— la aprendiste en el módulo 6 de la guía de fundamentos de testing. Aquí no vamos a re-explicar qué es la cobertura; vamos a convertirla en una puerta: de un número que miras en tu terminal a un umbral que rompe un build en el CI. Si necesitas refrescar cómo se genera y se lee un reporte de cobertura, ese es el lugar. Y el otro lado de la frontera: los tests flaky —los que pasan a veces y fallan a veces— son el módulo 7, el que sigue. Aquí, cuando una puerta se pone roja, es por una razón determinista y clara; la inconsistencia es tema del próximo módulo.

El detector de humo del edificio, no el termómetro

Piensa en dos aparatos que cuelgan del techo de un edificio de oficinas. El primero es un termómetro: mide la temperatura y la muestra en una pantallita. Si hay un incendio, el termómetro sube y sube —28°, 40°, 65°—, y refleja la verdad con toda fidelidad. Pero no hace nada más. Alguien tiene que estar mirando la pantalla, notar que el número subió, entender que eso significa fuego, y actuar. Si nadie mira, el edificio se quema con un termómetro perfectamente honesto marcando la catástrofe.

El segundo aparato es un detector de humo con rociadores. También mide —sensa las partículas en el aire—, pero tiene un umbral y una consecuencia: cuando el humo pasa cierto nivel, no se limita a mostrarlo, dispara la alarma y abre los rociadores. No depende de que alguien esté mirando. La medición cruza una línea y algo pasa automáticamente. El detector convierte "hay humo" de un dato que hay que vigilar en una acción que se ejecuta sola.

Tu pipeline, hasta este módulo, era un termómetro excelente. Medía la cobertura de Reservo y te la mostraba —65%, 84%, 91%—, con total honestidad. Pero dependía de que tú miraras el número, entendieras que bajó, y actuaras. Una puerta de calidad es el detector de humo: le pones un umbral a la métrica —"cobertura mínima 80%"— y una consecuencia —"si baja de ahí, el build se rompe"—, y a partir de ahí el CI actúa solo. Nadie tiene que estar mirando la pantalla. Cuando alguien intenta mergear un cambio que hunde la cobertura por debajo de la línea, la puerta dispara la alarma —el build rojo— y el cambio no entra. La medición dejó de ser un dato que se vigila y se volvió una condición que se impone.

Una puerta de calidad es una métrica más un umbral más una consecuencia automática. No se limita a medir la calidad (como un termómetro); rompe el build cuando la métrica cruza la línea (como un detector de humo con rociadores). Convierte un número que hay que vigilar en una condición que se impone sola.

Reservo, tal como lo dejamos — y la función que se coló sin tests

Seguimos con Reservo, el sistema de reservas de salas de un coworking que venimos probando desde el primer módulo. Lógica pura de Python: sin base de datos, sin red, sin relojes escondidos. Sus piezas, por si necesitas refrescar:

  • Room (id, name, capacity, hourly_cents), Member (id, name, tier: "basic" o "pro"), Booking (con su campo price_cents, el start, el end como rango medio-abierto [start, end), y su status).
  • Las funciones núcleo: price_cents(room, member, hours), refund_cents(booking, price_paid_cents, now), overlaps, is_available, book, y el Calendar que guarda las reservas en memoria.
  • Los números-ancla, el checksum de toda la guía: basic 3 h → 7500, pro 3 h → 6000 (20% de descuento), basic 1 h → 2500, y el reembolso sobre 6000 pagados: 6000 si cancelas 72 h antes (≥ 48 h, 100%), 3000 a 36 h (24–48 h, 50%), 0 a 12 h (< 24 h).

Todo eso ya lo tienes probado, con una suite que da verde. Para este módulo, Reservo estrenó una función nueva que junta dos piezas que ya conoces: cancel_with_refund, que cancela una reserva —liberando su horario en el calendario— y, en la misma operación, calcula cuánto dinero se le devuelve al miembro según la política de reembolso. Es una función legítima y útil. El problema —el que da vida a todo el módulo— es que llegó al repositorio sin tests.

# reservo/cancellations.py
from datetime import datetime

from reservo.calendar import Calendar
from reservo.models import Booking
from reservo.refunds import refund_cents


def cancel_with_refund(calendar: Calendar, booking: Booking, now: datetime) -> int:
    """Cancela `booking` y devuelve el reembolso en centavos."""
    if booking.status == "cancelled":
        raise ValueError("booking is already cancelled")
    refund = refund_cents(booking, booking.price_cents, now)
    calendar.cancel(booking)
    return refund


def refund_reason(booking: Booking, now: datetime) -> str:
    """Razon legible del reembolso, para el correo de confirmacion."""
    hours_before = (booking.start - now).total_seconds() / 3600
    if hours_before >= 48:
        return "full refund (cancelled 48h or more before start)"
    if hours_before >= 24:
        return "half refund (cancelled 24 to 48h before start)"
    return "no refund (cancelled less than 24h before start)"

Léelo con cuidado, porque este código funciona —no tiene bugs a la vista—. Pero "funciona" y "está probado" son dos cosas distintas, y esa distinción es justo la que una puerta de cobertura vuelve visible. Ahora mismo, la suite de Reservo pasa entera en verde, y sin embargo estas dos funciones no las ejercita ningún test. Si mañana alguien rompe refund_reason —invierte una condición, cambia un umbral— la suite seguiría verde, porque no hay nada que mire esa función. La cobertura es lo único que delata este hueco, y una puerta de cobertura es lo único que lo impide.

El primer contacto: la puerta que rompe el build

Vamos a ver la puerta más común de todas, ejecutada de verdad. La herramienta es pytest-cov, un plugin de pytest que mide la cobertura mientras corre la suite, y su bandera estrella para este módulo es --cov-fail-under=N: "si la cobertura total queda por debajo de N por ciento, termina con un exit code de fallo". Recordemos por qué el exit code importa tanto (lo viste en el módulo 2): el CI no lee la salida de texto, lee el exit code del comando. Un 0 es verde; cualquier otro número es rojo y detiene el pipeline. --cov-fail-under es, literalmente, la palanca que convierte un número de cobertura en un exit code —y por eso es una puerta—.

La corrida es sobre la máquina donde escribo esto: Python 3.14.0, pytest 9.1.1, coverage 7.15.2, pytest-cov 7.1.0. El comando pide la cobertura del paquete reservo (--cov=reservo) y pone la puerta en 80 (--cov-fail-under=80):

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

Qué esperar. Con la suite tal como está —con cancel_with_refund y refund_reason sin tests—, esto sale, medido de verdad:

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m6
plugins: cov-7.1.0
collected 10 items

tests/test_availability.py ....                                          [ 40%]
tests/test_pricing.py ...                                                [ 70%]
tests/test_refunds.py ...
ERROR: Coverage failure: total of 65 is less than fail-under=80
                                                                         [100%]

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

Name                       Stmts   Miss  Cover
----------------------------------------------
reservo/__init__.py            0      0   100%
reservo/calendar.py           30      7    77%
reservo/cancellations.py      17     17     0%
reservo/models.py             12      2    83%
reservo/pricing.py            10      1    90%
reservo/refunds.py             9      0   100%
----------------------------------------------
TOTAL                         78     27    65%
FAIL Required test coverage of 80% not reached. Total coverage: 65.38%
============================== 10 passed in 0.03s ==============================

Detente en dos líneas. La primera: 10 passed. Los diez tests pasaron. Si esto fuera solo la suite, el build sería verde y todos contentos. La segunda: FAIL Required test coverage of 80% not reached. Total coverage: 65.38%, y arriba, ERROR: Coverage failure: total of 65 is less than fail-under=80. Ahí está la puerta hablando. Los tests pasan, pero la cobertura total es 65.38% —mira la fila reservo/cancellations.py, con 0%: sus 17 líneas no las toca ningún test—, y como 65 es menor que 80, la puerta rompe el build. Ahora comprobemos que de verdad rompió el build mirando el exit code, que es lo único que el CI leería:

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

Exit code 1. No es 0. En el runner, ese 1 pintaría el step de rojo y detendría el merge, exactamente igual que si un test hubiera fallado —aunque los diez tests pasaron—. Eso es una puerta de calidad en acción: la métrica cruzó la línea, y el build se rompió solo, sin que nadie tuviera que mirar el número y decidir. El termómetro se volvió detector de humo.

¿Y cómo se apaga la alarma? No maquillando el número: escribiendo el test que falta. En la lección 3 vas a agregar el test de cancel_with_refund, ver la cobertura subir a 91.03%, y ver la misma puerta pasar con exit code 0. Por ahora, quédate con la imagen: la puerta no te pide que subas un número, te pide que pruebes el código que dejaste sin probar. Esa es la diferencia entre una puerta que ayuda y un fetiche que estorba, y es el hilo que atraviesa todo el módulo.

Qué es y qué no es una puerta de calidad

Vale la pena dejar clara la anatomía antes de zambullirnos, para que las próximas lecciones caigan en su lugar.

Una puerta de calidad es tres cosas juntas: una métrica (aquí, el porcentaje de cobertura), un umbral (80%), y una consecuencia automática (romper el build —exit code distinto de cero— si la métrica queda por debajo del umbral). Las tres son necesarias. Sin métrica no hay qué medir. Sin umbral no hay línea que cruzar. Y sin consecuencia automática tienes un termómetro, no una puerta: un número que alguien tiene que mirar y decidir. La puerta existe precisamente para que nadie tenga que mirar y decidir: la política se declara una vez, en el YAML, y el CI la impone en cada push.

Lo que una puerta de calidad no es: no es una medida de calidad de verdad. Una puerta de cobertura vigila cuánto código se ejecuta cuando corren los tests, y eso es una condición necesaria —código que no se ejecuta jamás fue verificado— pero no suficiente: código que se ejecuta puede estar verificado de mentira, con un test tautológico que no afirma nada (la lección 7 entera es sobre esto). Confundir "80% de cobertura" con "80% de calidad" es el error que convierte una herramienta útil en un fetiche dañino. La puerta te dice qué código no tocaron los tests; no te dice si los tests que sí lo tocan valen algo. Ese juicio sigue siendo tuyo.

Y una honestidad de la guía, la misma de siempre: el workflow de CI se ejecuta en un runner de GitHub, que aquí no tenemos. Así que el YAML de la puerta lo vas a escribir y leer como contenido —te muestro cómo se ve y cómo se leería su log—, mientras que las corridas de pytest --cov y coverage report son reales, hechas en local con Python 3.14.0, coverage 7.15.2 y pytest-cov 7.1.0. Cuando cite "65.38%" o "exit code 1", ese número lo medí ejecutando; cuando muestre un log de CI con un step rojo, ese es el formato honesto de cómo se vería, no una captura de un runner fantasma. La puerta que rompe el build en tu terminal es la misma que rompería el build en el runner, porque es el mismo comando leyendo el mismo exit code.

Errores comunes

Creer que "la suite verde" significa "el código está probado". Qué pasa: el equipo ve 10 passed y concluye que Reservo está cubierto, sin notar que una función entera (cancel_with_refund) no la toca ningún test. Por qué pasa: "verde" es una señal fuerte y fácil de sobreinterpretar; el ojo lee "pasan los tests" como "todo el código funciona", cuando solo significa "el código que los tests ejercitan funciona". Cómo detectarlo: mira la cobertura, no solo el conteo de tests. Una suite verde con 65% de cobertura te está diciendo que un tercio del código nunca se ejecutó en las pruebas. Cómo corregirlo: agrega una puerta de cobertura, que hace visible —y bloqueante— justo ese hueco que el conteo de tests esconde. Verde de tests y cobertura son dos preguntas distintas: "¿pasa lo que probé?" y "¿cuánto probé?".

Poner una puerta pero mirar la salida en vez del exit code. Qué pasa: alguien agrega --cov-fail-under=80 al comando, ve el reporte de cobertura impreso, y cree que con eso ya "tiene la puerta", sin verificar que el comando de verdad termina con exit code de fallo cuando debe. Por qué pasa: el reporte de cobertura es visualmente llamativo y da la sensación de que "algo está pasando", pero el CI no lee ese texto, lee el exit code. Cómo detectarlo: corre el comando y haz echo "exit code: $?" justo después; si no es 1 (o 2, según la herramienta) cuando la cobertura está baja, la puerta no muerde. Cómo corregirlo: prueba la puerta a propósito con la cobertura por debajo del umbral y confirma el exit code distinto de cero, igual que compruebas que un test "muerde" rompiendo el código. Una puerta que nunca viste romper el build es una puerta en la que no deberías confiar.

Confundir la cobertura con la calidad. Qué pasa: el equipo trata el porcentaje de cobertura como si fuera la nota del código, y persigue subirlo como fin en sí mismo —"llegamos al 95%, vamos por el 100%"—. Por qué pasa: es un número, y los números son fáciles de perseguir; da la ilusión de progreso medible. Cómo detectarlo: pregúntate "si subo este número escribiendo un test que no afirma nada, ¿la puerta me deja?". Si la respuesta es sí (y lo es, como verás en la lección 7), el número no mide calidad, mide ejecución. Cómo corregirlo: usa la cobertura como lo que es —un detector de código sin probar, un piso de seguridad— y no como una calificación. La lección 6 y la 7 desarrollan esta distinción, que es la línea entre una puerta que ayuda y un fetiche que hace daño.

Ejercicios

Ejercicio 1 — Termómetro o detector. Para cada situación, di si describe un pipeline que solo mide (termómetro) o uno con una puerta (detector de humo), y explica en una frase qué lo distingue. (a) "El CI imprime un reporte de cobertura al final de cada corrida, y el líder lo revisa los viernes." (b) "El CI corre pytest --cov=reservo --cov-fail-under=80 y el merge se bloquea si la cobertura baja de 80." (c) "Tenemos un dashboard que grafica la cobertura semana a semana."

Ver solución
  • (a) Termómetro. Mide (imprime el reporte) pero no impone: la consecuencia depende de que un humano lo revise y actúe. Si el líder se va de vacaciones o no lo mira un viernes, la cobertura puede desplomarse sin que nada lo frene. Es medición honesta sin puerta.
  • (b) Detector de humo (puerta). Tiene las tres piezas: métrica (cobertura), umbral (80) y consecuencia automática (merge bloqueado vía exit code distinto de cero). Nadie tiene que mirar; el CI impone la línea en cada push. Es una puerta de calidad.
  • (c) Termómetro (más bonito). Un dashboard es medición visualizada —muy útil para ver tendencias—, pero sigue dependiendo de que alguien mire el gráfico y actúe. Graficar la caída no la impide; solo la hace más fácil de notar después. Sin un umbral que rompa el build, no hay puerta.

La regla: si la degradación puede pasar sin que nada automático lo impida, es un termómetro. Solo (b) tiene la consecuencia que define una puerta.

Ejercicio 2 — Predice el efecto de la puerta. En la corrida de la lección, la cobertura de Reservo fue 65.38% con cancel_with_refund sin tests, y la puerta --cov-fail-under=80 rompió el build con exit code 1. Sin correr nada, predice: si un compañero, en vez de escribir el test que falta, borra cancellations.py del proyecto (elimina la función no probada), ¿qué le pasaría a la cobertura total y a la puerta? ¿Es un buen arreglo?

Ver solución

La cobertura total subiría y probablemente la puerta pasaría. Razón: la cobertura es un porcentaje —líneas ejecutadas sobre líneas totales—. cancellations.py aportaba 17 líneas totales y 0 ejecutadas, arrastrando el promedio hacia abajo. Al borrarlo, esas 17 líneas sin cubrir desaparecen del denominador, y el porcentaje de lo que queda sube (el resto del paquete estaba mucho mejor cubierto). La puerta, mecánicamente, pasaría.

Pero es un arreglo pésimo, y esto es importante: subir la cobertura quitando código sin probar en vez de probándolo engaña a la puerta sin cumplir su propósito. Si cancel_with_refund es una función que Reservo necesita, borrarla para que la puerta pase equivale a apagar el detector de humo en vez de apagar el fuego. La puerta te estaba señalando un hueco real —código útil sin verificar—; la respuesta correcta es escribir el test (lección 3), no eliminar la funcionalidad. Este ejercicio ilustra por qué la cobertura es una condición necesaria pero manipulable: el número se puede mover por las razones equivocadas, y por eso el juicio humano —"¿este código debe existir y estar probado?"— sigue siendo insustituible.

Ejercicio 3 — ¿Qué módulo o guía resuelve esto? Para cada situación, di si la resuelve este módulo (puertas de calidad) o si le toca a otro módulo de esta guía o a una guía hermana, y nómbralo en una frase. (a) "Quiero que el build se rompa si la cobertura de Reservo baja de 80%." (b) "No entiendo qué es una línea 'sin cubrir' en un reporte de cobertura." (c) "Un test de Reservo pasa a veces y falla a veces sin que cambie el código." (d) "Quiero exigir que los tests smoke pasen antes de permitir un merge."

Ver solución
  • (a) Romper el build si la cobertura baja de 80% → este módulo, y en concreto la lección 3 (--cov-fail-under y el exit code). Es la definición misma de una puerta de cobertura.
  • (b) Qué es una línea "sin cubrir" → la guía de fundamentos de testing, su módulo 6 (la cobertura como herramienta local). Aquí damos por sabido cómo se lee un reporte; lo que agregamos es convertirlo en puerta. Si el concepto base no está claro, ese es el lugar de repaso.
  • (c) Un test que pasa a veces y falla a veces → el módulo 7 (flaky tests en CI). Un test no determinista es un flaky, y su tratamiento —retry, cuarentena, el fallo que solo ocurre en CI— es del módulo que sigue. Una puerta de calidad falla de forma determinista; un flaky es lo contrario.
  • (d) Exigir que los smoke pasen antes de mergear → este módulo, la lección 5 (puertas por marcador). Es una puerta que no mira cobertura sino un subconjunto de tests, pero es una puerta al fin: umbral (que pasen), consecuencia (bloquear el merge).

La regla mecánica: si la pregunta es "¿cómo hago que el CI imponga un mínimo?", es este módulo. "¿Qué es la cobertura?" es fundamentals M6. "¿Por qué es inconsistente?" es el módulo 7.

Resumen y siguiente paso

En esta lección instalaste la idea que sostiene el módulo: un pipeline que solo mide es un termómetro; una puerta de calidad es un detector de humo. Medir la cobertura y mostrarla depende de que alguien mire el número y actúe; una puerta le pone un umbral y una consecuencia automática —romper el build— para que la degradación no pueda pasar en silencio. Es el salto de un CI que te dice cómo está la calidad a uno que la exige.

Lo viste latir con una demo local real: Reservo estrenó cancel_with_refund, una función legítima que se coló al repositorio sin tests. La suite seguía dando 10 passed —verde—, pero pytest --cov=reservo --cov-fail-under=80 reveló que la cobertura total era 65.38%, con cancellations.py en 0%, y como 65 < 80, la puerta rompió el build con exit code 1. Los tests pasaban y aun así el build se puso rojo, porque la métrica cruzó la línea. También quedó clara la honestidad de la guía: las corridas de cobertura son reales, el YAML de la puerta es contenido que aprendes a leer y escribir, y —clave— la puerta no te pide maquillar un número, te pide probar el código que dejaste sin probar.

Antes de avanzar deberías poder: explicar con tus palabras la diferencia entre medir e imponer calidad; nombrar las tres piezas de una puerta (métrica, umbral, consecuencia); decir por qué una suite verde no garantiza que el código esté probado; y explicar por qué el exit code —y no el reporte impreso— es lo que hace que una puerta muerda.

Lo que sigue, en la lección 2, es desarmar el concepto de puerta con calma, antes de volver a los comandos: qué la distingue de un simple reporte, por qué cualquier métrica con un umbral puede ser una puerta, y por qué el exit code es el idioma en que una puerta le habla al CI. Con esa base conceptual firme, la lección 3 vuelve a --cov-fail-under para exprimirlo de verdad —el ciclo completo de rojo a verde, ejecutado paso a paso—.

Recursos

  • pytest-cov: fail_under y el reporte de cobertura — la documentación del plugin que usamos para --cov y --cov-fail-under, la referencia canónica de este módulo. Aquí solo la asomamos; en la lección 3 la abrimos a fondo.
  • Coverage.py: fail_under — la opción equivalente en la herramienta coverage de la que pytest-cov se apoya. Nota que el umbral vive en la configuración de [report]; lo veremos con .coveragerc en la lección 3.
  • Coverage.py: introducción y cómo se mide — el otro lado de la frontera: qué es la cobertura y cómo se genera un reporte en local, tema del módulo 6 de la guía de fundamentos de testing. Si "línea sin cubrir" o "statement coverage" no te suenan del todo, repásalo antes de seguir; aquí lo damos por sabido.
  • Exit codes de pytest — la tabla oficial de qué significa cada código de salida de pytest, el idioma en que la puerta le habla al CI. El 1 que viste (fallo) y el 0 (éxito) son las dos caras de toda puerta.