Módulo 8: Proyecto — un pipeline de CI para Reservo

6. La puerta de cobertura

Descripción

Hasta ahora tu pipeline se pone rojo por una sola razón: un test falla. Pero hay una forma de que la calidad se erosione sin que ningún test falle: que llegue código nuevo sin tests. Una función de cuarenta líneas que nadie ejercita no rompe la suite —no hay test que se ponga rojo—, simplemente no está probada, y el pipeline, ciego a lo que no se mide, la deja pasar en verde. La capa que apilamos aquí cierra esa ceguera: la puerta de cobertura, un umbral que rompe el build cuando la fracción del código ejercitada por los tests cae por debajo de un mínimo.

Es la capa del módulo 6, y cambia la naturaleza del pipeline: de "corre los tests" a "exige un piso de calidad". Vas a ver --cov-fail-under=N romper el build de verdad —cobertura 88%, puerta a 95% que falla con exit 1, puerta a 85% que pasa con exit 0—, leer el reporte term-missing que dice qué líneas faltan, y enfrentar la decisión más importante y peor entendida del tema: dónde poner el umbral. Porque el 100% es un fetiche, y Reservo te lo va a demostrar con un hueco de cobertura que es, de hecho, código perfectamente cubierto —por otra celda de la matriz—.

Al terminar vas a saber poner una puerta de cobertura que rompe el build, leer qué falta, y —lo que separa a quien copia --cov-fail-under=100 de quien piensa— decidir un umbral defendible y distinguir un hueco que hay que cerrar de uno que hay que aceptar.

Conexión con el módulo: esta es la quinta capa, y se apoya en todas las anteriores. La puerta mide sobre la suite que corre (lección 2) en un entorno reproducible (lección 3) —sin versiones pinneadas, el 88% de hoy no sería comparable con el de mañana—. Y conecta hacia atrás con la matriz (lección 4) de una forma que esta lección revela: el hueco de cobertura de report_pages en tu máquina es la rama que otra celda de la matriz sí ejercita. La cobertura es una herramienta que ya conociste como medida local en la guía de fundamentos; aquí se vuelve una puerta en CI, con el poder de bloquear un merge.

El inspector que solo revisa lo que le muestran

Piensa en un inspector de obra que certifica que un edificio es seguro. Recorre las plantas que el constructor le muestra —el lobby, tres pisos de oficinas—, revisa que todo esté a norma, y firma. Pero el constructor no le mostró el sótano, ni la instalación eléctrica del ático. El inspector firma "seguro" sobre lo que vio, y calla sobre lo que no. Si el sótano tiene una falla, su firma no la cubre —él nunca la revisó—, pero el sello dice "aprobado" y todos asumen que revisó todo.

El problema no es el inspector; es que nadie mide qué fracción del edificio revisó de verdad. "Aprobado" sin un porcentaje de cobertura es una firma que puede esconder un sótano sin revisar. La solución es exigir un mínimo auditado: "no firmes 'seguro' si revisaste menos del 90% de las áreas críticas". Ahora la firma tiene un piso: si el constructor esconde el sótano, la cobertura baja del 90%, y el inspector no puede certificar hasta revisarlo. El umbral convierte "revisé lo que me mostraron" en "revisé al menos esta fracción, o no firmo".

La cobertura de tests es qué fracción de tu código ejercitan los tests, y la puerta de cobertura es ese mínimo auditado. Sin puerta, tu suite verde es el inspector firmando sobre lo que le mostraron: los tests que existen pasan, pero nada te dice cuánto código quedó sin revisar. Con puerta —--cov-fail-under=85—, el pipeline se niega a certificar en verde si la fracción ejercitada cae bajo el umbral. Si alguien agrega una función sin tests (el sótano escondido), la cobertura baja, la puerta rompe el build, y el código no entra hasta que se pruebe. El umbral convierte "los tests pasan" en "los tests pasan y cubren al menos esto".

La puerta de cobertura es el mínimo auditado del inspector: no basta con que los tests que existen pasen, tiene que probarse al menos cierta fracción del código, o el build no pasa. Convierte el código nuevo sin tests de invisible en algo que frena el merge.

Medir la cobertura: --cov y term-missing

pytest-cov (que ya está en tu requirements-dev.txt, arrastrando coverage) mide la cobertura mientras corre la suite. La bandera --cov=reservo le dice qué paquete medir; --cov-branch le pide medir también la cobertura de ramas —no solo si cada línea se ejecutó, sino si cada if/else tomó sus dos caminos, el verdadero y el falso—, que es justo lo que llena las columnas Branch y BrPart de la tabla; y --cov-report=term-missing imprime el reporte con las líneas que faltan:

python -m pytest --cov=reservo --cov-branch --cov-report=term-missing

Qué esperar (salida real de la suite de Reservo en Python 3.14.0):

================================ tests coverage ================================
Name                  Stmts   Miss Branch BrPart  Cover   Missing
-----------------------------------------------------------------
reservo/__init__.py       0      0      0      0   100%
reservo/calendar.py       9      1      0      0    89%   15
reservo/models.py         9      0      0      0   100%
reservo/pricing.py        5      0      2      0   100%
reservo/refunds.py        7      0      4      0   100%
reservo/reports.py        7      2      2      1    67%   14-16
reservo/schedule.py      20      3      8      2    82%   15, 16->13, 40-41
-----------------------------------------------------------------
TOTAL                    57      6     16      3    88%
========================= 13 passed, 1 skipped in 0.03s =========================

Lee la tabla columna por columna. Stmts es cuántas sentencias tiene el archivo; Miss, cuántas no ejercitó ningún test; Branch y BrPart, las ramas (los if/else) y cuántas quedaron a medias; Cover, el porcentaje; Missing, los números de línea sin cubrir. La última fila, TOTAL ... 88%, es el número que la puerta va a vigilar.

Detente en dos filas que cuentan la historia de esta lección:

  • reservo/schedule.py ... 82% ... 15, 16->13, 40-41 — faltan la línea 15, la rama 16→13, y las líneas 40-41. Las 40-41 son la función cancel, que ningún test de la suite base ejercita (la dejamos sin test a propósito). La 16->13 es la rama que salta las reservas canceladas. Estos son huecos reales: código de Reservo que ningún test toca. Se cierran escribiendo tests.
  • reservo/reports.py ... 67% ... 14-16 — este es el hueco interesante, y no se cierra con un test. Las líneas 14-16 son la rama else de report_pages —el fallback manual para Python < 3.12—. En esta máquina, que corre 3.14, esa rama jamás se ejecuta: Python entra por el if sys.version_info >= (3, 12) y ni mira el else. Es código muerto en 3.14. Ningún test que escribas en 3.14 puede cubrir esas líneas, porque en 3.14 son inalcanzables.

Esa segunda fila es la clave del módulo, y la retomamos en un momento. Primero, la puerta.

La puerta que rompe el build: --cov-fail-under

La bandera --cov-fail-under=N convierte la medición en un veredicto: si la cobertura total es menor que N, pytest devuelve un exit code distinto de cero —el build se rompe—. Míralo con dos umbrales, ejecutados de verdad.

Puerta exigente, en 95%:

python -m pytest --cov=reservo --cov-branch --cov-fail-under=95

Qué esperar (real; la cobertura es 88%, menor que 95):

TOTAL                    57      6     16      3    88%
FAIL Required test coverage of 95% not reached. Total coverage: 87.67%
echo "exit code: $?"
exit code: 1

Exit code 1: la puerta rompió el build. Todos los tests pasaron —13 passed—, pero la cobertura (87.67%, que se redondea a 88% en la tabla) no llegó al 95% exigido, así que el pipeline se pone rojo. En el runner, con protección de rama, ese rojo bloquearía el merge. Fíjate en el mensaje: Required test coverage of 95% not reached —la puerta te dice exactamente por qué falló—.

Puerta razonable, en 85%:

python -m pytest --cov=reservo --cov-branch --cov-fail-under=85

Qué esperar (real; 88% ≥ 85%):

TOTAL                    57      6     16      3    88%
Required test coverage of 85% reached. Total coverage: 87.67%
echo "exit code: $?"
exit code: 0

Exit code 0: la puerta pasó. La misma suite, la misma cobertura del 88%, pero contra un umbral de 85% el veredicto es verde. La diferencia entre el rojo y el verde no estuvo en el código ni en los tests —fueron idénticos—; estuvo en dónde pusiste la puerta. Y ahí está la decisión que define si tu puerta ayuda o estorba.

Dónde poner el umbral: el 100% es un fetiche

La tentación es poner la puerta en 100% —"que se pruebe todo"—. Reservo demuestra por qué eso es un error, no una virtud. Recuerda la fila de reports.py: su rama else (el fallback para Python < 3.12) es inalcanzable en 3.14. Ningún test que escribas en tu máquina puede cubrir esas líneas. Una puerta al 100% en la celda de 3.14 sería imposible de satisfacer —fallaría siempre, no por código mal probado, sino por código que en esta versión no existe—. Perseguir el 100% ahí no mejora nada; solo te obliga a hacer trampa (excluir líneas, escribir tests falsos) para acallar una puerta mal calibrada.

Y aquí está el giro que conecta con la matriz (lección 4): esas líneas de reports.py sí se cubren —en la celda de 3.11—. Allá, sys.version_info >= (3, 12) es falso, Python entra por el else, y la rama del fallback se ejecuta y se mide como cubierta. El "hueco" del 67% en 3.14 no es un hueco de calidad; es código de otra versión, que la celda de esa versión ejercita. La cobertura, mirada celda por celda, nunca será 100% en reports.py para una sola versión, y está perfectamente bien: entre las celdas de la matriz, ambas ramas quedan cubiertas. Una puerta que exigiera 100% por celda castigaría a Reservo por hacer lo correcto —soportar varias versiones con ramas específicas—.

La lección de fondo: la cobertura es un piso, no una meta. Un umbral defendible para Reservo es 85% —por debajo del 88% real, con margen para que un cambio menor no rompa el build por ruido, pero lo bastante alto para que agregar una función de cuarenta líneas sin tests haga caer la cobertura y dispare la puerta—. El número exacto se elige con criterio: alto para que atrape el código sin probar, no tan alto que reviente en código legítimamente no cubrible (ramas de otra versión, if __name__ == "__main__", código defensivo que no se puede provocar). Un equipo maduro pone la puerta un poco por debajo de su cobertura actual y la sube con el tiempo, no la clava en 100% el primer día.

Cerrar el hueco vs. bajar la puerta

Cuando la puerta falla, tienes dos caminos honestos y uno tramposo. El tramposo: excluir líneas del conteo con # pragma: no cover sin razón, para inflar el número. Los honestos:

Cerrar el hueco —escribir el test que falta—. La suite base no prueba cancel (líneas 40-41 de schedule.py). Si agregas un test que reserva, cancela y verifica que el hueco vuelve a estar disponible, esas líneas se cubren. Medido de verdad, agregar ese test sube la cobertura:

reservo/schedule.py      20      0      8      1    96%   16->13
-----------------------------------------------------------------
TOTAL                    57      3     16      2    93%

De 88% a 93%schedule.py saltó de 82% a 96%— por un solo test que ejercita código real que estaba sin probar. Este es el buen uso de la cobertura: te señaló un hueco real (una función sin ningún test), y lo cerraste probando lo que faltaba. La cobertura como herramienta de diagnóstico, no como número a maquillar.

Bajar (o calibrar) la puerta —cuando el hueco no es un defecto—. La rama else de reports.py no se cierra con un test en 3.14; se acepta como cubierta-por-la-matriz. La puerta se calibra para no exigir lo imposible: un umbral del 85% que convive con ese 67% de reports.py, sabiendo que la celda de 3.11 lo compensa. Bajar la puerta aquí no es rendirse; es reconocer que el 100% por celda es un fetiche que castiga el soporte multi-versión.

La disciplina: cuando la puerta falle, pregúntate por qué falta la cobertura. Si es código real sin probar (cancel), ciérralo con un test. Si es código inalcanzable en esta configuración (la rama de otra versión), calibra la puerta. Nunca lo tercero —maquillar el número— porque eso convierte al inspector en cómplice.

Errores comunes

Poner la puerta en 100% y celebrarlo. Qué pasa: alguien clava --cov-fail-under=100 sintiéndose riguroso, y el build falla para siempre porque hay código legítimamente no cubrible —la rama else de otra versión, un if __name__ == "__main__", un except defensivo que no se puede provocar—. Para "arreglarlo", empieza a escribir tests falsos o a excluir líneas sin criterio, degradando la suite. Por qué pasa: 100% suena a excelencia; en realidad es una meta que ignora que parte del código no se puede o no se debe ejercitar en cada configuración. Cómo detectarlo: si tu puerta te obliga a escribir tests que no prueban nada real solo para subir el número, la puerta está mal calibrada. Cómo corregirlo: pon la puerta un poco por debajo de tu cobertura real y honesta, y súbela con el tiempo; trata el 100% como sospechoso, no como trofeo.

Confundir un hueco de versión con un hueco de calidad. Qué pasa: alguien ve reservo/reports.py ... 67% en la celda de 3.14 y concluye "esa función está mal probada", y se pone a escribir tests para cubrir la rama else —que en 3.14 es inalcanzable—, perdiendo el tiempo. Por qué pasa: la tabla de cobertura no distingue "sin probar" de "inalcanzable en esta versión"; ambos aparecen como líneas faltantes. Cómo detectarlo: mira qué líneas faltan; si son una rama guardada por sys.version_info (o por SO, o por una condición que en esta configuración es falsa), es código de otra configuración, no un hueco de calidad. Cómo corregirlo: entiende la cobertura como un número por configuración, y confía en que la matriz cubre las ramas de cada versión en la celda donde viven. El total honesto es el de la matriz completa, no el de una celda.

Medir la cobertura sin entornos pinneados. Qué pasa: el pipeline mide 88% hoy y 86% en dos semanas sin que nadie toque el código, porque una versión nueva de coverage cuenta las ramas distinto, y la puerta rompe el build por un fantasma. Por qué pasa: la cobertura es un número sobre un entorno, y si el entorno flota, el número flota. Cómo detectarlo: si la cobertura cambia sin cambios de código ni de tests, sospecha de una versión de coverage/pytest-cov que se movió. Cómo corregirlo: la capa de la lección 3 —pinnear pytest-cov (que clava coverage)— para que el 88% de hoy sea comparable con el de mañana. Una puerta solo es justa si mide sobre un suelo firme.

Ejercicios

Ejercicio 1 — Elige el umbral y defiéndelo. La cobertura real de Reservo es 88%. Un compañero propone --cov-fail-under=100; otro, --cov-fail-under=50. Ambos son malos por razones distintas. Explica por qué, y propón un umbral defendible con su justificación.

Ver solución

--cov-fail-under=100 es malo por exigir lo imposible. Reservo tiene código legítimamente no cubrible en cada celda: la rama else de report_pages es inalcanzable en 3.12+ (y la del if, inalcanzable en 3.11). Una puerta al 100% por celda fallaría siempre, no por código mal probado, sino por ramas de otra versión que en esta no existen. Para acallarla, el equipo terminaría escribiendo tests falsos o excluyendo líneas sin criterio, degradando la suite. El 100% suena riguroso pero es un fetiche que castiga el soporte multi-versión.

--cov-fail-under=50 es malo por no proteger nada. La cobertura real ya es 88%, muy por encima de 50. Una puerta en 50% nunca se dispararía con el código actual, y —peor— dejaría pasar una caída enorme: alguien podría borrar la mitad de los tests, hundir la cobertura a 60%, y la puerta seguiría en verde. Una puerta muy por debajo de la cobertura real es decorativa: existe pero no protege.

Un umbral defendible: --cov-fail-under=85. Está por debajo del 88% real (con un colchón de ~3 puntos para que un cambio menor no rompa el build por ruido de redondeo o una rama), pero lo bastante alto para morder: si alguien agrega una función de cuarenta líneas sin tests, la cobertura caería por debajo de 85 y la puerta dispararía. La regla: pon la puerta un poco por debajo de tu cobertura honesta actual, alta para atrapar código sin probar, no tan alta que reviente en código no cubrible. Y súbela con el tiempo, a medida que la suite madura, en vez de clavarla en 100% el primer día.

Ejercicio 2 — Cerrar o calibrar. El reporte muestra dos huecos: schedule.py línea 40-41 (la función cancel, sin ningún test) y reports.py línea 14-16 (la rama else del fallback, en una corrida de Python 3.14). Para cada uno, di si lo cierras con un test o lo aceptas calibrando la puerta, y por qué.

Ver solución

schedule.py 40-41 (cancel): se cierra con un test. Es código real y alcanzable de Reservo que ningún test ejercita —la función cancel existe, funciona, y en 3.14 (o cualquier versión) se puede llamar—. El hueco es un defecto genuino de la suite: hay lógica sin probar. Se cierra escribiendo el test que falta (reservar, cancelar, verificar que el hueco vuelve a estar disponible), lo que medido sube la cobertura de 88% a 93%. Este es el buen uso de la puerta: te señaló código sin probar y lo cerraste probándolo.

reports.py 14-16 (rama else, en 3.14): se acepta calibrando la puerta. Es código inalcanzable en esta configuración: en 3.14, sys.version_info >= (3, 12) es verdadero, así que Python entra por el if y jamás ejecuta el else. Ningún test escrito en 3.14 puede cubrir esas líneas, porque en 3.14 son código muerto. No es un defecto de la suite; es una rama de otra versión, que la celda de 3.11 de la matriz sí ejercita. Se acepta poniendo la puerta en un umbral (85%) que convive con ese 67% de reports.py, sabiendo que la matriz cubre la rama donde vive. Intentar "cerrarlo" con un test en 3.14 sería perder el tiempo o hacer trampa.

La regla que distingue los dos casos: pregúntate "¿este código se puede ejecutar en esta configuración?". Si sí y no hay test (cancel), es un hueco real: ciérralo. Si no (rama de otra versión), es cobertura-por-configuración: calíbrala, confiando en la celda que sí la ejercita.

Ejercicio 3 — La puerta que no mordía. Un equipo tiene --cov-fail-under=70 y cobertura real del 92%. Un compañero sube un pull request que agrega un módulo de 200 líneas con solo dos tests triviales, hundiendo la cobertura al 74%. El CI pasa en verde. ¿Falló la puerta? ¿Qué cambiarías?

Ver solución

La puerta no falló técnicamente —hizo exactamente lo que se le pidió: romper el build solo si la cobertura cae por debajo de 70%, y 74% ≥ 70%, así que pasó—. Pero falló en su propósito: dejó entrar un módulo grande casi sin probar (200 líneas, dos tests triviales) sin siquiera una advertencia. El problema es que la puerta estaba calibrada muy por debajo de la cobertura real (70% cuando el proyecto vivía en 92%), así que tenía 22 puntos de colchón para absorber degradaciones enormes antes de disparar. Una puerta así es casi decorativa: existe, pero permite que la calidad se erosione mucho antes de reaccionar.

Qué cambiaría, dos cosas. Primero, subir la puerta cerca de la cobertura real: con el proyecto en 92%, un umbral de ~90% (un colchón pequeño, no de 22 puntos) haría que la caída al 74% rompiera el build de inmediato, forzando al autor a probar su módulo antes de mergear. Segundo, y más potente, agregar una comprobación de cobertura del diff (patch coverage) —herramientas como Codecov o diff-cover miden qué fracción de las líneas nuevas del pull request está cubierta, no solo el total—. Con eso, un módulo de 200 líneas con dos tests fallaría por baja cobertura del cambio, aunque el total del proyecto siguiera alto, porque la degradación se diluye en el total pero salta en el diff. La lección: una puerta de cobertura total muy holgada protege mal; ponla cerca de tu número real, y complementa con cobertura del diff para que el código nuevo sin probar no se esconda en el promedio.

Resumen y siguiente paso

En esta lección apilaste la quinta capa: la puerta de cobertura, que cambia el pipeline de "corre los tests" a "exige un piso de calidad". Mediste la cobertura de Reservo —88%— con --cov=reservo --cov-branch --cov-report=term-missing, leíste qué líneas faltan, y viste --cov-fail-under romper el build de verdad: exit 1 contra una puerta del 95%, exit 0 contra una del 85%, con el mismo código y los mismos tests. La diferencia entre el rojo y el verde no estuvo en el código; estuvo en dónde pusiste la puerta.

Y enfrentaste la decisión de fondo: el 100% es un fetiche. El hueco del 67% en reports.py no es código mal probado, es la rama else de otra versión, inalcanzable en 3.14 y cubierta por la celda de 3.11 de la matriz —el vínculo que ata esta capa con la lección 4—. Aprendiste a distinguir el hueco que se cierra con un test (cancel, que sube la cobertura a 93%) del que se acepta calibrando la puerta (la rama de versión), y a nunca maquillar el número. La cobertura es un piso y una herramienta de diagnóstico, no una meta ni un trofeo.

Antes de avanzar deberías poder: medir cobertura y leer term-missing; poner una puerta con --cov-fail-under y predecir si pasa o rompe; elegir un umbral defendible y argumentarlo; y distinguir un hueco real de calidad de un hueco de configuración que la matriz cubre.

Lo que sigue, en la lección 7, es la última capa antes del proyecto: la política de flaky. Tu pipeline ya es exigente —corre en varias versiones, con puerta de cobertura—, pero le falta una respuesta a un problema que la propia lección 5 sembró: un test que falla de forma intermitente, a veces por el orden no determinista que el paralelismo introduce. Un flaky en CI erosiona la confianza como nada —si el rojo a veces miente, ¿por qué creerle?—. Vas a ver el debate del retry ejecutado de verdad (--reruns), la cuarentena por marcador, y por qué la política honesta no es "reintenta hasta que pase" sino "parche, ticket, y arreglo de raíz".

Recursos

  • coverage.py — el motor de medición detrás de pytest-cov: cómo cuenta sentencias y ramas, qué es branch coverage, y cómo se configura en pyproject.toml. La referencia del número que la puerta vigila.
  • pytest-cov — el plugin que integra coverage con pytest: --cov, --cov-report=term-missing, y la bandera --cov-fail-under que convierte la medición en puerta. La documentación de lo que ejecutaste.
  • coverage.py: Excluding code from coverage — cómo excluir código legítimamente no cubrible (# pragma: no cover) con criterio, para las ramas de versión o el código defensivo, sin maquillar el número.
  • coverage.py: Branch coverage — cómo se cuentan las ramas (las columnas Branch y BrPart de la tabla) y por qué report_pages, con su if/else por versión, nunca da 100% en una sola celda. El detalle técnico detrás del hueco de reports.py. En la guía hermana testing-fundamentals-and-tdd la cobertura se enseña como herramienta local de diagnóstico; aquí la convertimos en puerta de CI.