Módulo 6: Puertas de calidad — cobertura y umbrales que rompen el build
2. Qué es una puerta de calidad
Descripción
En la lección anterior viste una puerta romper el build y la comparaste con un detector de humo. Ahora vamos a desarmar el concepto con calma, porque entender qué es exactamente una puerta —antes de memorizar cualquier comando— es lo que te permite después diseñar puertas propias, saber cuáles poner y cuáles no, y no confundir la herramienta con un fetiche. Una puerta de calidad no es una bandera de pytest ni una línea de YAML; es una idea que esas herramientas implementan, y la idea es más simple y más poderosa de lo que parece.
Al terminar esta lección vas a poder definir una puerta de calidad por sus tres partes —métrica, umbral y consecuencia— y explicar por qué las tres son necesarias. Vas a entender la diferencia entre un pipeline que reporta (te dice el número) y uno que impone (rompe el build si el número cruza la línea), y por qué esa diferencia lo cambia todo. Vas a ver que el exit code es el idioma en que una puerta le habla al CI —el único idioma que el CI escucha— y por eso toda puerta, sin importar qué mida, termina traduciéndose en un 0 o un número distinto de cero. Y vas a ver que la cobertura es solo una métrica posible: cualquier número con un umbral —cantidad de fallos, tests que faltan, tamaño del binario— puede convertirse en una puerta con la misma receta.
Conexión con el módulo: esta lección es la teoría que sostiene las cinco siguientes. La 1 te dio la intuición (termómetro contra detector); esta la vuelve precisa (las tres partes, el exit code). La lección 3 toma esta definición y la ejecuta con --cov-fail-under, viendo las tres partes en un comando real. La 4 juega con el umbral —qué pasa cuando el piso es fijo y la métrica cae sin cruzarlo—. La 5 cambia la métrica —de cobertura a "que los smoke pasen"— demostrando que la receta es la misma. La 6 y la 7 discuten cuándo la consecuencia ayuda y cuándo hace daño. Todo lo que viene son variaciones de las tres partes que defines aquí. Por eso vale la pena instalarlas bien antes de correr un solo comando más.
El torniquete del metro, no el letrero de "por favor pague"
Imagina la entrada de una estación de metro con dos diseños posibles. El primero es un letrero: "Por favor, pague su boleto antes de pasar", con una caja de honestidad al lado. El letrero comunica la regla con toda claridad. Mide, incluso: si pones una cámara, sabes cuántos pagaron y cuántos no. Pero no impone nada. Depende enteramente de la buena voluntad de cada persona. El que quiere colarse, se cuela; el letrero no lo detiene, solo lo mira pasar.
El segundo diseño es un torniquete. Tiene la misma regla —hay que pagar para pasar— pero la implementa distinto: una barrera física que no se abre si no metiste el boleto. No apela a tu buena voluntad; hace que colarse sea imposible (o al menos, deliberado y difícil). El torniquete convierte "por favor pague" de una sugerencia en una condición. La regla dejó de depender de que cada quien decida cumplirla.
Una puerta de calidad es el torniquete; un reporte de cobertura es el letrero. Los dos "saben" la regla —"el código debe estar cubierto al 80%"—. Pero el reporte la comunica y confía en que alguien la haga cumplir; la puerta la impone con una barrera que no se abre —el build rojo— si la regla no se cumple. Y fíjate en la anatomía del torniquete, porque es exactamente la de una puerta: hay una medición (¿metiste el boleto?), un umbral (el boleto tiene que ser válido), y una consecuencia automática (la barrera se abre o no). Ninguna de las tres sobra. Un torniquete sin medición no sabría qué hacer; sin umbral dejaría pasar cualquier papel; sin la barrera automática sería, otra vez, solo un letrero.
Una puerta de calidad implementa una regla con una barrera automática, no con un pedido de buena voluntad. Como el torniquete del metro, tiene una medición, un umbral y una consecuencia que se ejecuta sola —la barrera que no se abre—. Un reporte es el letrero: comunica la regla pero confía en que alguien la haga cumplir.
Las tres partes, una por una
Desarmemos la puerta en sus tres piezas, porque cada lección del módulo mueve una de ellas.
La métrica. Es el número que la puerta vigila. En una puerta de cobertura, la métrica es "qué porcentaje de las líneas del código ejecutó la suite". Pero podría ser otra cosa: "cuántos tests fallaron" (métrica: número de fallos), "cuántos tests marcados como críticos pasaron", "cuántos segundos tardó la suite", "cuántas advertencias del linter hay". La métrica tiene que ser algo medible automáticamente —una máquina tiene que poder calcularla sin juicio humano—, porque toda la gracia de la puerta es que se impone sin que nadie mire. "¿El código es elegante?" no puede ser una métrica de puerta; "¿la cobertura es ≥ 80%?" sí.
El umbral. Es la línea que la métrica no debe cruzar. En --cov-fail-under=80, el umbral es 80. El umbral es una decisión, no un dato: nadie te dice que tiene que ser 80; lo eliges tú, y esa elección es la parte más delicada de toda la puerta. Demasiado bajo, la puerta no protege de nada (un umbral de 10% deja pasar casi cualquier cosa). Demasiado alto, la puerta bloquea trabajo legítimo y empuja a trampas para cumplirlo (el 100% como fetiche, lección 7). El umbral correcto es un tema de la lección 6, pero quédate desde ya con esto: el umbral es la parte de la puerta donde vive el juicio, y elegirlo mal es la forma más común de que una puerta buena se vuelva un estorbo.
La consecuencia. Es lo que pasa cuando la métrica cruza el umbral. En una puerta de CI, la consecuencia es siempre la misma en su forma: el comando termina con un exit code distinto de cero, lo que pinta el step de rojo y detiene el pipeline. La consecuencia es lo que separa una puerta de un reporte: sin ella, mides pero no impones. Y es automática por definición —no "alguien decide bloquear el merge", sino "el build se rompe solo"—. Esa automaticidad es el valor entero de la herramienta: la política se declara una vez y se aplica en cada push, sin depender de que nadie esté vigilando.
Junta las tres y tienes la frase completa: "si la cobertura (métrica) baja de 80% (umbral), rompe el build (consecuencia)". Cambia la métrica y tienes una puerta distinta —"si algún test smoke (métrica) falla, rompe el build"—. Cambia el umbral y ajustas su severidad. Cambia la consecuencia de "romper" a "solo advertir" y —cuidado— dejas de tener una puerta y vuelves a tener un letrero. Todo el módulo es jugar con estas tres perillas.
El exit code: el único idioma que el CI escucha
Aquí hay una idea que ya rozaste en el módulo 2 y que este módulo vuelve central: el CI no lee texto, lee exit codes. Cuando un step de un workflow corre un comando —pytest, coverage report, lo que sea—, el runner no interpreta el reporte bonito que el comando imprime. Mira una sola cosa: el número entero con el que el comando terminó. Por convención universal de Unix, 0 significa "todo bien" y cualquier otro número significa "algo falló". El runner traduce ese número a un color: 0 → verde, distinto de cero → rojo. Y un step rojo detiene el job, que es lo que bloquea el merge.
Por eso toda puerta de calidad, sin importar qué métrica vigile, termina haciendo lo mismo: convertir su veredicto en un exit code. --cov-fail-under=80 no "avisa" que la cobertura está baja; hace que pytest termine con exit code 1 cuando lo está. Esa es toda la magia. La puerta es, en esencia, un traductor: toma una métrica (65.38%), la compara con un umbral (80), y emite el idioma que el CI entiende (exit code 1 = rojo). El reporte de cobertura que ves impreso es para ti, el humano; el exit code es para la máquina, el CI. Confundirlos —creer que porque ves el reporte la puerta está funcionando— es el error de la lección 1: la puerta muerde solo si emite el exit code correcto, y eso hay que comprobarlo.
Un matiz que verás en la lección 3 y conviene anticipar: distintas herramientas usan distintos números para "fallo". pytest (y por lo tanto pytest-cov) usa exit code 1 para "hubo fallos o la puerta no se cumplió". El CLI de coverage usa exit code 2 específicamente para "la cobertura quedó por debajo de fail_under". Para el CI da igual cuál sea —cualquier número distinto de cero es rojo—, pero para ti importa saber leerlos, porque un 2 te dice "fue la puerta de cobertura" y un 1 de pytest podría ser "fue la puerta o un test que falló". El exit code no es solo verde/rojo; es un pequeño mensaje sobre qué salió mal.
Ejemplo trabajado: la misma métrica, reporte contra puerta
Veamos, con la suite de Reservo, la diferencia entre reportar e imponer, sobre exactamente el mismo número. Primero, reportar: corremos la cobertura sin ninguna puerta, solo pidiendo el reporte. La suite es la del estado incompleto (con cancel_with_refund sin tests).
python -m pytest --cov=reservo > /dev/null 2>&1; echo "exit code: $?"
Qué esperar (medido de verdad en Python 3.14.0):
exit code: 0
Fíjate bien: exit code 0, verde. Pedimos el reporte de cobertura (--cov=reservo) pero no pusimos umbral, así que no hay puerta. La cobertura era 65.38% —baja—, pero como no le pedimos a pytest que impusiera nada, terminó en 0. Es el letrero: midió, incluso imprimió el número, pero no impuso. Un CI con este comando estaría verde con 65% de cobertura, sin pestañear.
Ahora, imponer: el mismo comando, la misma suite, el mismo 65.38%, pero agregando la puerta:
python -m pytest --cov=reservo --cov-fail-under=80 > /dev/null 2>&1; echo "exit code: $?"
Qué esperar (medido de verdad):
exit code: 1
Exit code 1, rojo. Nada cambió salvo que agregamos --cov-fail-under=80. La cobertura sigue siendo 65.38%; lo que cambió es que ahora hay un umbral y una consecuencia: como 65 < 80, pytest tradujo ese hecho al idioma del CI —exit code 1—. El torniquete apareció. El mismo número que el reporte mostraba con indiferencia, la puerta lo convierte en un build roto. Esa es, en una sola comparación, toda la diferencia entre medir e imponer: no está en la métrica (idéntica), está en si hay un umbral que la traduce a un exit code.
Cualquier métrica puede ser una puerta
La cobertura es la puerta más famosa, pero no es especial. La receta —métrica + umbral + consecuencia vía exit code— aplica a cualquier número que una máquina pueda calcular. Vale la pena ver la variedad, porque te libera de pensar que "puerta de calidad" es sinónimo de "cobertura":
- Puerta de tests que pasan. Métrica: ¿fallaron tests? Umbral: cero fallos. Consecuencia: pytest ya la implementa solo —si un test falla, exit code 1—. Es la puerta que tienes desde el módulo 2; el corazón del pipeline es, literalmente, una puerta de "cero fallos".
- Puerta por marcador. Métrica: ¿pasaron los tests
smoke? Umbral: todos. Consecuencia:pytest -m smoketermina en 1 si alguno falla. Es la lección 5. - Puerta de cobertura. Métrica: % de líneas ejecutadas. Umbral: 80%. Consecuencia:
--cov-fail-under=80. Es la lección 3. - Puertas fuera de este módulo. Un linter que rompe el build si hay errores de estilo (métrica: número de violaciones; umbral: cero). Un chequeo de tipos con
mypyque falla si hay errores de tipo. Un escaneo de seguridad que bloquea si encuentra una dependencia vulnerable. Todas son la misma idea con otra métrica.
Ver esta lista tiene un propósito: cuando en la lección 5 cambiemos de la cobertura a los marcadores, no será un tema nuevo, será la misma puerta con otra métrica. Y cuando en la lección 6 discutamos si una puerta vale la pena, la pregunta será siempre la misma —¿esta métrica, con este umbral, protege algo que de verdad importa?— sin importar cuál sea la métrica. Aprendiste una receta, no un comando.
Errores comunes
Poner la consecuencia en "advertir" en vez de "romper", y creer que tienes una puerta. Qué pasa: alguien configura la cobertura para que imprima una advertencia amarilla cuando está baja, pero el build sigue verde. Cree que puso una puerta; puso un letrero más vistoso. Por qué pasa: una advertencia se siente como una consecuencia, pero si no cambia el exit code, el CI la ignora y el merge procede. Cómo detectarlo: pregúntate "¿el merge se bloquea de verdad, o solo aparece un texto?". Si el cambio entra igual, no hay puerta. Cómo corregirlo: la consecuencia de una puerta tiene que ser un exit code distinto de cero que detenga el pipeline; cualquier cosa que no rompa el build es medición, no imposición. (Hay un lugar legítimo para advertir sin romper —cuando estás introduciendo una puerta gradualmente— pero entonces sé honesto: es un letrero de transición, no una puerta todavía.)
Elegir el umbral sin pensarlo, copiándolo de un tutorial. Qué pasa: alguien pone --cov-fail-under=100 porque "más es mejor" o --cov-fail-under=50 porque venía en un ejemplo, sin conectar el número con su proyecto. Por qué pasa: el umbral es la parte que parece un detalle —solo un número— cuando en realidad es la decisión central de la puerta. Cómo detectarlo: si no puedes explicar por qué tu umbral es ese y no cinco puntos más o menos, lo copiaste. Cómo corregirlo: el umbral es donde vive el juicio (lección 6). Un punto de partida honesto es "ponlo donde estás hoy" —si tu cobertura es 84%, pon la puerta en 84 para no retroceder— y súbelo con un trinquete cuando mejores (lección 4), en vez de perseguir un número redondo aspiracional.
Leer el reporte impreso como prueba de que la puerta funciona. Qué pasa: alguien ve el reporte de cobertura salir en la terminal y concluye "la puerta está puesta", sin verificar el exit code. Por qué pasa: el reporte es lo visible y llamativo; el exit code es invisible a menos que lo pidas con echo $?. Cómo detectarlo: como en el ejemplo trabajado, corre el comando con la cobertura por debajo del umbral y confirma que el exit code es distinto de cero. Si es 0, tienes el reporte pero no la puerta. Cómo corregirlo: recuerda que el reporte es para el humano y el exit code para el CI; la puerta vive en el exit code, no en el texto. Comprueba siempre que muerde antes de confiar en ella.
Ejercicios
Ejercicio 1 — Nombra las tres partes. Para la puerta pytest -m smoke (que corre solo los tests marcados como smoke y rompe el build si alguno falla), identifica sus tres partes: (a) la métrica, (b) el umbral, (c) la consecuencia. Luego di qué cambiarías para convertirla en una puerta que exija que toda la suite pase, no solo los smoke.
Ver solución
Las tres partes de pytest -m smoke:
- (a) La métrica: ¿cuántos de los tests marcados
smokefallaron? (o, dicho como estado: ¿pasaron todos los smoke?). - (b) El umbral: cero fallos entre los smoke —todos deben pasar—.
- (c) La consecuencia:
pytest -m smoketermina con exit code 1 si algún smoke falla, lo que pinta el step de rojo y bloquea el merge.
Para exigir que toda la suite pase, cambias la métrica: de "¿pasaron los smoke?" a "¿pasaron todos los tests?". En la práctica es quitar el filtro -m smoke y correr pytest a secas —métrica: fallos en toda la suite; umbral: cero; consecuencia: exit code 1 si algún test falla—. Nota que solo tocaste una perilla (la métrica); el umbral (cero fallos) y la forma de la consecuencia (exit code) son idénticos. Esa es la ventaja de pensar en las tres partes: para cambiar una puerta, identificas cuál perilla mover.
Ejercicio 2 — Letrero o torniquete. Un equipo dice: "Tenemos control de calidad estricto. El CI calcula la cobertura, la publica en un comentario del pull request, y tenemos la política de que nadie debe mergear con menos de 80%." Sin correr nada, decide: ¿es una puerta (torniquete) o un reporte (letrero)? ¿Qué falta, si algo falta, para que sea una puerta de verdad?
Ver solución
Es un letrero, no un torniquete, a pesar de la palabra "estricto". Tiene métrica (la cobertura calculada) y hasta un umbral declarado (80%), pero le falta la pieza que define una puerta: la consecuencia automática. "Tenemos la política de que nadie debe mergear con menos de 80%" es una regla que depende de que cada persona la cumpla y de que los revisores la vigilen —exactamente el "por favor pague" del letrero—. El día que alguien tenga prisa, o que un revisor no note que la cobertura bajó, el merge con 70% entra sin que nada lo impida.
Lo que falta para volverlo un torniquete: que el CI rompa el build cuando la cobertura baja de 80 —un --cov-fail-under=80 cuyo exit code distinto de cero bloquee el merge—, o una regla de protección de rama que exija ese check verde antes de permitir el merge. La distinción es fina pero total: publicar el número y confiar en la disciplina es medir; hacer que el merge sea imposible por debajo del umbral es imponer. La palabra "política" es la pista: una política que un humano puede saltarse es un letrero; una que la máquina impone es una puerta.
Ejercicio 3 — El exit code cuenta la historia. Corres dos comandos sobre Reservo y anotas sus exit codes. Comando A: pytest --cov=reservo → exit 0. Comando B: pytest --cov=reservo --cov-fail-under=80 → exit 1. Los dos midieron la misma cobertura (65.38%). Explica por qué uno dio 0 y el otro 1, y qué te dice eso sobre dónde vive la "puerta".
Ver solución
Los dos comandos midieron lo mismo —65.38%— y corrieron los mismos tests (que pasaron todos). La diferencia está entera en la presencia del umbral:
- Comando A (
--cov=reservo, sin--cov-fail-under) le pidió a pytest reportar la cobertura, pero no le dio ningún umbral que imponer. Sin umbral no hay línea que cruzar, así que pytest no tiene motivo para fallar: los tests pasaron → exit 0. Midió, imprimió el número, y se quedó tranquilo. Es el letrero. - Comando B (agregando
--cov-fail-under=80) le dio un umbral. Ahora pytest compara 65.38% con 80, ve que está por debajo, y traduce ese hecho a un fallo → exit 1. Es el torniquete.
Lo que esto revela: la "puerta" no vive en la métrica (idéntica en A y B) ni en los tests (pasaron en ambos). Vive en el umbral que se traduce a un exit code. Agregar --cov-fail-under=80 no cambió nada de lo que se midió; cambió qué se hace con la medición —de mostrarla a imponerla—. Por eso una puerta es, en el fondo, solo un umbral conectado a un exit code: quítale el umbral y vuelve a ser un reporte; quítale la traducción a exit code (déjalo en "advertencia") y vuelve a ser un letrero.
Resumen y siguiente paso
En esta lección desarmaste la puerta de calidad en sus tres partes: una métrica (un número que una máquina puede calcular, como la cobertura), un umbral (la línea que no debe cruzarse, la decisión donde vive el juicio) y una consecuencia automática (romper el build vía exit code distinto de cero). Las tres son necesarias: sin métrica no hay qué medir, sin umbral no hay línea, sin consecuencia tienes un letrero y no un torniquete. La diferencia entre reportar e imponer no está en lo que se mide, sino en si hay un umbral que traduce la medición a un exit code —lo comprobaste corriendo el mismo 65.38% con y sin puerta: exit 0 contra exit 1—.
También quedó claro que el exit code es el único idioma que el CI escucha: toda puerta, mida lo que mida, termina emitiendo un 0 (verde) o un número distinto de cero (rojo), y el reporte impreso es para el humano, no para la máquina. Y viste que la cobertura no tiene nada de especial: la misma receta —métrica + umbral + consecuencia— convierte cualquier número medible (tests que pasan, marcadores, violaciones del linter) en una puerta. Aprendiste una receta reutilizable, no un comando suelto.
Antes de avanzar deberías poder: nombrar las tres partes de una puerta y dar un ejemplo de cada una; explicar por qué el exit code —y no el reporte— es donde vive la puerta; distinguir un reporte de una puerta por si la consecuencia rompe el build o solo avisa; y reconocer que la cobertura es una métrica entre muchas posibles.
Lo que sigue, en la lección 3, es tomar esta definición y ejecutarla de verdad, de principio a fin: el ciclo completo de --cov-fail-under sobre Reservo —la puerta rompiendo el build con la suite incompleta, tú agregando el test que falta, y la misma puerta pasando a verde—, más su primo del CLI de coverage con su exit code distinto y el archivo .coveragerc que define qué se mide. Instalaste la teoría; toca verla funcionar en tu terminal.
Recursos
pytest-cov: opciones de configuración — dónde vive--cov-fail-undery cómo se combina con--cov. La referencia directa de la puerta de cobertura que ejecutarás en la lección 3.- Exit codes de pytest — la tabla oficial de qué significa cada código de salida (0 = todo pasó, 1 = hubo fallos, y los demás). El idioma en que la puerta le habla al CI; vale la pena tenerla a mano.
- Coverage.py:
fail_under— el umbral en la herramientacoverage, con su nota de que usa un exit code propio (2) cuando no se alcanza. Lo contrastaremos con el de pytest en la lección 3. - Sobre los checks requeridos y la protección de rama — GitHub — cómo GitHub convierte un check en rojo en un merge bloqueado de verdad. Es la pieza que hace que la consecuencia de tu puerta se imponga a nivel de repositorio, no solo del step.