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

8. Mini-proyecto: una puerta de cobertura para el CI de Reservo

Descripción

Llegó el momento de juntar todo el módulo en una entrega. En las siete lecciones anteriores aprendiste qué es una puerta de calidad, cómo --cov-fail-under la implementa con su exit code, cómo hacerla fallar en una caída con un trinquete, cómo montar una puerta smoke por marcador, cuándo una puerta ayuda y cuándo estorba, y por qué el 100% como fetiche empuja a tautologías. Ahora lo aplicas de principio a fin: pones una puerta de cobertura al CI de Reservo y haces que rompa el build cuando falta un test, con la evidencia local de que de verdad muerde.

Este no es un ejercicio suelto: es el trabajo real que harías al endurecer el CI de un proyecto. Vas a producir cinco entregables concretos —el workflow YAML con la puerta de cobertura y la puerta smoke, el .coveragerc que la hace honesta, la demostración local del ciclo rojo→verde (la puerta rompe el build con cancel_with_refund sin probar, tú agregas el test, la puerta pasa), la comprobación de que la puerta smoke también muerde, y una nota de decisión que justifica qué umbral merece Reservo y por qué no el 100%—. Todo con la honestidad de la guía: el YAML es contenido (no hay runner aquí), pero las corridas de pytest --cov son reales, ejecutadas en Python 3.14.0 con coverage 7.15.2 y pytest-cov 7.1.0.

Conexión con el módulo: esta lección cierra el arco. La 1 te dio el concepto (medir contra imponer); la 2 lo definió (métrica, umbral, consecuencia); la 3 lo ejecutó (el ciclo rojo→verde); la 4 lo afinó (el trinquete contra las caídas); la 5 lo diversificó (la puerta smoke); la 6 lo juzgó (cuándo ayuda); la 7 lo desnudó (el fetiche del 100%). El mini-proyecto los ejerce todos a la vez sobre Reservo. Y mira hacia adelante: al terminar tendrás un CI que impone calidad, no solo la mide —lo que hace urgente la pregunta del módulo 7—: ¿qué pasa cuando un test que la puerta exige es flaky, y pasa a veces y falla a veces sin que cambie el código? Cierras las puertas de calidad; el módulo 7 ataca la inconsistencia.

El encargo

Eres responsable del CI de Reservo. El pipeline base ya existe (módulo 2): corre la suite en cada push. Tu trabajo ahora es endurecerlo con puertas de calidad, porque el equipo notó que se coló código sin tests (cancel_with_refund) sin que nada lo frenara. Tu encargo:

  1. Escribir el workflow que corre la suite con una puerta de cobertura (--cov-fail-under) que rompa el build cuando la cobertura baje del umbral.
  2. Configurar .coveragerc para que la puerta mida el paquete reservo y vea el código no importado.
  3. Demostrar en local que la puerta muerde: con cancel_with_refund sin probar rompe el build (exit ≠ 0), y al agregar el test pasa (exit 0).
  4. Agregar una puerta smoke como job aparte, escalonado, y demostrar que también muerde.
  5. Justificar, con el criterio de las lecciones 6 y 7, qué umbral merece Reservo y por qué un trinquete honesto, no el 100%.

Intenta cada paso por tu cuenta antes de mirar la solución. La solución completa está al final, pero el aprendizaje está en construirla tú.

Paso 1 — El workflow con la puerta de cobertura

Escribe .github/workflows/tests.yml. Debe correr en cada push y PR, instalar Python y dependencias, y correr pytest --cov=reservo --cov-fail-under=<umbral>. Recuerda: la puerta vive en --cov-fail-under, no en --cov; sin el umbral solo reportas.

Piénsalo antes de seguir: ¿qué umbral pones —80 fijo, o el nivel real de Reservo—? ¿Qué exit code hará que el CI rompa el build?

Paso 2 — El .coveragerc que no miente

Configura qué se mide. Sin esto, coverage incluiría los tests e ignoraría el código no importado (como cancellations.py), dando un número inflado y ciego.

Piénsalo: ¿qué opción hace que coverage descubra los archivos del paquete aunque ningún test los importe?

Paso 3 — Demuestra que la puerta muerde (rojo→verde)

Corre la puerta en local, primero con cancel_with_refund sin probar (debe romper el build), luego agregando el test (debe pasar). Captura ambos exit codes: la evidencia de que la puerta de verdad rompe el build cuando falta un test es la mitad de la entrega.

Paso 4 — La puerta smoke, escalonada

Agrega un job que corra pytest -m smoke antes de la suite completa (needs:). Marca los tests-ancla, y demuestra que la puerta smoke muerde rompiendo una regla crítica.

Paso 5 — La nota de decisión

Justifica el umbral. ¿80 fijo o 91 (el nivel real)? ¿Por qué no 100? Aplica la regla del umbral honesto y la ley de Goodhart.


Solución completa

Entregable 1 — El workflow YAML con las puertas

# .github/workflows/tests.yml
name: tests

on: [push, pull_request]

jobs:
  smoke:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v5

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.14"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Smoke gate (critical tests only)
        run: python -m pytest -m smoke

  coverage-gate:
    needs: smoke          # solo corre si la puerta smoke paso
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v5

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.14"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Full suite with coverage gate
        run: python -m pytest --cov=reservo --cov-fail-under=91

Las decisiones y su porqué:

  • --cov-fail-under=91, no 80 ni 100. El 91 es el nivel real de Reservo (la cobertura de la suite completa), un trinquete honesto: atrapa cualquier caída sin bloquear trabajo que mantenga la cobertura, y sin empujar tautologías. (Lecciones 4, 6 y 7; justificado a fondo en el entregable 5.)
  • --cov=reservo mide el código, no los tests. (Lección 3.)
  • Dos jobs escalonados con needs: smoke: la puerta smoke (rápida, crítica) corre primero; la suite completa con cobertura solo si smoke pasa. Feedback veloz sobre lo esencial. (Lección 5.)
  • on: [push, pull_request] — la puerta protege tanto los pushes como los PRs hacia la rama principal. (Módulo 2.)

El requirements.txt que el workflow instala:

# requirements.txt
pytest==9.1.1
pytest-cov==7.1.0

Reservo es stdlib pura; las únicas dependencias son pytest y pytest-cov (para la puerta de cobertura). Pinnearlas es la lección del módulo 3: instalaciones deterministas para que la celda corra lo mismo que tú.

Entregable 2 — El .coveragerc

# .coveragerc
[run]
source = reservo

[report]
show_missing = True

source = reservo hace dos cosas críticas: limita la medición al paquete reservo (fuera los archivos de test, que si no inflarían el número al 100% de sí mismos) y hace que coverage descubra todos los archivos del paquete, importados o no. Sin esto, cancellations.py —que ningún test importa hasta que lo probamos— sería invisible, y la puerta se cegaría justo al código sin probar que debe cazar. Esta línea es lo que hace honesta a la puerta. (Lección 3.)

Entregable 3 — La demostración local: rojo → verde

Esta es la evidencia de que la puerta muerde, ejecutada de verdad. La honestidad de la guía: el YAML es contenido, pero esto es real —Python 3.14.0, pytest 9.1.1, coverage 7.15.2, pytest-cov 7.1.0—.

Estado 1: cancel_with_refund sin probar → la puerta rompe el build.

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

Qué esperar (salida real):

============================= 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=91
                                                                         [100%]

================================ 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      7    77%   24, 26, 34, 49-51, 55
reservo/cancellations.py      17     17     0%   7-36
reservo/models.py             12      2    83%   34-35
reservo/pricing.py            10      1    90%   14
reservo/refunds.py             9      0   100%
--------------------------------------------------------
TOTAL                         78     27    65%
FAIL Required test coverage of 91% not reached. Total coverage: 65.38%
============================== 10 passed in 0.03s ==============================
python -m pytest --cov=reservo --cov-fail-under=91 > /dev/null 2>&1; echo "exit code: $?"
exit code: 1

Los diez tests pasan (10 passed), pero cancellations.py está en 0% y la cobertura total es 65.38%. Como 65 < 91, la puerta rompe el build: exit code 1. En el runner, este 1 bloquearía el merge. La puerta hizo exactamente lo que se le pidió: rompió el build porque falta un test.

Estado 2: agrego el test que falta → la puerta pasa.

Escribo tests/test_cancellations.py que ejercita cancel_with_refund de verdad (verificando el reembolso, que el horario se libera, y que cancelar dos veces falla —tests que verifican, no tautologías, lección 7—). Vuelvo a correr:

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

Qué esperar (salida real):

collected 13 items

tests/test_availability.py ....                                          [ 30%]
tests/test_cancellations.py ...                                          [ 53%]
tests/test_pricing.py ...                                                [ 76%]
tests/test_refunds.py ...                                                [100%]

================================ tests coverage ================================
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/pricing.py            10      1    90%   14
reservo/refunds.py             9      0   100%
--------------------------------------------------------
TOTAL                         78      7    91%
Required test coverage of 91% reached. Total coverage: 91.03%
============================== 13 passed in 0.03s ==============================
python -m pytest --cov=reservo --cov-fail-under=91 > /dev/null 2>&1; echo "exit code: $?"
exit code: 0

cancellations.py pasó a 100%, la cobertura total a 91.03%, y la puerta pasó: exit code 0. El ciclo completo, demostrado: la puerta rompió el build por un test faltante (exit 1), y al agregarlo pasó a verde (exit 0). No bajamos el umbral ni borramos código: escribimos el test que faltaba. Eso es una puerta de calidad haciendo su trabajo.

Entregable 4 — La puerta smoke también muerde

Registramos el marcador en pytest.ini y marcamos los tres tests-ancla críticos:

# pytest.ini
[pytest]
markers =
    smoke: fast, critical tests that must pass before any merge (the smoke gate).

Con @pytest.mark.smoke en el precio básico, el descuento pro y el reembolso completo, la puerta smoke corre solo esos tres:

python -m pytest -m smoke
collected 13 items / 10 deselected / 3 selected
======================= 3 passed, 10 deselected in 0.02s =======================

Verde (exit 0): 3 seleccionados, 10 deseleccionados. Ahora comprobamos que muerde, rompiendo el descuento pro (20% → 25%):

# tras cambiar PRO_DISCOUNT_PERCENT de 20 a 25 en reservo/pricing.py
python -m pytest -m smoke
collected 13 items / 10 deselected / 3 selected

tests/test_pricing.py::test_pro_three_hours FAILED                       [ 66%]
...
>       assert price_cents(focus, pro, 3) == 6000
E       AssertionError: assert 5625 == 6000

FAILED tests/test_pricing.py::test_pro_three_hours - AssertionError: assert 5625 == 6000
================== 1 failed, 2 passed, 10 deselected in 0.03s ==================
python -m pytest -m smoke > /dev/null 2>&1; echo "exit code: $?"
exit code: 1

Exit code 1. La puerta smoke atrapó el bug del precio pro (assert 5625 == 6000) en 0.03 s, corriendo solo 3 tests. En el pipeline escalonado, este rojo detendría todo en el primer job —la suite completa con cobertura ni arrancaría (needs: smoke)—, ahorrando el tiempo de la corrida cara. Restauramos el descuento a 20% y la puerta smoke vuelve a verde (exit 0).

Entregable 5 — La nota de decisión: ¿qué umbral merece Reservo?

Reservo merece un trinquete honesto en 91% (su nivel real), no un piso de 80 ni una meta de 100%. El razonamiento, con las reglas de las lecciones 4, 6 y 7:

  • 91 en vez de 80 (el trinquete contra la caída). La cobertura real de Reservo es 91.03%. Un piso de 80 dejaría pasar caídas: alguien podría agregar código sin tests y bajar la cobertura a 84% sin romper el build (84 > 80), erosionando la calidad en silencio (lección 4). Un umbral en 91 —pegado al nivel real— atrapa cualquier retroceso. Es el trinquete: solo sube. Cuando la cobertura mejore a 93 de forma estable, se sube el umbral a 93; nunca baja.
  • 91 en vez de 100 (evitar el fetiche). Poner la puerta en 100% empujaría al equipo a cerrar el último 9% con tests tautológicos —que ejecutan sin verificar, subiendo el número sin proteger nada (lección 7)—. Las líneas que hoy faltan (models.py 83%, calendar.py 87%, ramas defensivas raras) cuestan más de probar de verdad de lo que valen, y perseguirlas a las malas produciría peores tests, no mejores. El 91 honesto de tests que muerden vale más que un 100% de cartón. Ley de Goodhart: en cuanto el 100% es la meta, deja de significar "bien probado".
  • El umbral es honesto, no aspiracional (lección 6). 91 es donde Reservo está, así que no bloquea ningún PR que mantenga la cobertura —es el primer guardia, protege sin estorbar—. Un 95 aspiracional rompería el build en trabajo sano y enseñaría al equipo a rodear la puerta.

Conclusión: --cov-fail-under=91, un trinquete en el nivel real, más la puerta smoke sobre los tres tests-ancla. Es lo que corresponde a lo que Reservo de verdad arriesga: la erosión silenciosa (que el trinquete frena) y lo crítico del negocio (que la smoke protege), sin el fraude que un 100% aspiracional produciría. La cobertura se usa como piso de seguridad y mapa de qué falta probar, no como calificación a maximizar.

Errores comunes

Entregar el YAML sin la evidencia de que la puerta muerde. Qué pasa: alguien escribe un --cov-fail-under correcto pero nunca comprueba que de verdad rompe el build cuando la cobertura está baja. Por qué pasa: el YAML "se ve bien" y da sensación de trabajo terminado. Cómo detectarlo: si no tienes una corrida con exit 1 (cobertura baja) y otra con exit 0 (tras agregar el test), no verificaste que la puerta muerde, solo escribiste intenciones. Cómo corregirlo: corre la puerta en ambos estados y captura los dos exit codes; esa evidencia es la mitad de la entrega, porque el YAML es contenido y la corrida es lo real. Una puerta que nunca viste romper el build es una puerta en la que no deberías confiar.

Poner el umbral en 100% "para maximizar la calidad". Qué pasa: alguien pone --cov-fail-under=100 creyendo que exige lo mejor, y el equipo cierra el último tramo con tautologías. Por qué pasa: 100% suena a excelencia. Cómo detectarlo: si tus tests de las ramas raras son assert x is not None, tu 100% es de cartón (lección 7). Cómo corregirlo: umbral honesto en el nivel real (91), que protege sin empujar al fraude. El número correcto casi nunca es 100; prefiere tests que muerden a un porcentaje redondo.

Olvidar el .coveragerc y medir lo equivocado. Qué pasa: sin source = reservo, la puerta incluye los tests (inflando el número) e ignora cancellations.py (el código sin probar), dando un verde falso. Por qué pasa: por defecto coverage mide lo que se ejecuta, y los tests se ejecutan. Cómo detectarlo: si tu reporte tiene filas test_*.py al 100% y no ves los módulos sin probar, mides lo equivocado. Cómo corregirlo: source = reservo para medir el código y descubrir los archivos no importados. Una puerta ciega al código sin probar es peor que ninguna: da falsa confianza.

Ejercicios

Ejercicio 1 — La puerta atrapa un segundo hueco. Después de tu trabajo, la cobertura de Reservo es 91% con la puerta en 91. Un compañero agrega notifications.py (los textos de correo) sin tests, bajando la cobertura a 83.53%. Sin correr nada, predice: ¿la puerta en 91 rompe el build? ¿Y una puerta de piso 80? Explica la diferencia y qué debería hacer el compañero.

Ver solución
  • La puerta en 91 (trinquete): rompe el build (exit 1). 83.53% < 91, así que --cov-fail-under=91 falla (total of 84 is less than fail-under=91). El trinquete atrapa la caída: entró código sin tests y la puerta lo delata.
  • Una puerta de piso 80: pasa (exit 0). 83.53% ≥ 80, así que un piso de 80 no vería la caída —el punto ciego de la lección 4—. El código sin probar se colaría por encima del piso.

La diferencia es exactamente por qué elegiste 91 y no 80: un piso lejano deja pasar caídas que un trinquete en el nivel real atrapa. La caída de 91 a 83.53% (casi ocho puntos, un módulo entero sin probar) es invisible para el piso 80 y roja para el trinquete 91.

Qué debería hacer el compañero: escribir los tests de notifications.py que verifiquen el contenido de los mensajes (no tautologías como assert msg is not None, sino assert "bk-1" in msg y demás), subiendo la cobertura de vuelta a 91 o más. Apagar el fuego, no bajar el umbral. Y si la nueva cobertura sube a 93, apretar el trinquete a 93.

Ejercicio 2 — Detecta el YAML con la puerta falsa. Un compañero entrega este workflow "con puerta de cobertura". Tiene un problema que hace que la puerta no muerda. Encuéntralo y corrígelo.

- name: Run tests with coverage
  run: |
    python -m pytest --cov=reservo || true
    echo "cobertura medida"
Ver solución

Hay dos problemas, y cualquiera hace que la puerta no muerda:

  1. Falta --cov-fail-under. El comando tiene --cov=reservo (que reporta la cobertura) pero no --cov-fail-under=N (que la impone). Sin umbral no hay puerta: solo mide (el letrero de la lección 2). La cobertura puede ser 10% y el step pasaría.
  2. El || true anula el exit code. Aunque hubiera --cov-fail-under, el || true al final hace que el comando siempre termine en exit 0, sin importar lo que devuelva pytest. || true significa "si el comando de la izquierda falla, corre true (que da exit 0) en su lugar", borrando el rojo. La consecuencia de la puerta —el exit code distinto de cero— queda neutralizada. Es como poner un torniquete y luego dejar la barrera abierta.

Corregido:

- name: Run tests with coverage gate
  run: python -m pytest --cov=reservo --cov-fail-under=91

Ahora sí: --cov-fail-under=91 pone el umbral (la puerta muerde si la cobertura baja de 91), y sin || true el exit code de fallo llega al CI y rompe el build. La lección: una puerta necesita las tres partes vivas —métrica (--cov), umbral (--cov-fail-under) y la consecuencia intacta (el exit code, que || true estaba matando)—. Cualquier eslabón roto y la puerta se vuelve un adorno.

Ejercicio 3 — Justifica el escalonado (o no). Tu pipeline de Reservo tiene la puerta smoke (0.02 s) escalonada antes de la suite completa con cobertura (0.03 s), con needs: smoke. Un compañero dice: "Reservo corre en centésimas de segundo; el escalonado solo agrega latencia. Corramos todo en paralelo." ¿Tiene razón para Reservo? ¿Cambiaría si Reservo tuviera una matriz de tres versiones que tarda 10 minutos?

Ver solución

Para Reservo tal como está, el compañero tiene razón. El escalonado con needs: smoke hace que la suite completa espere a que smoke termine, agregando latencia en el caso feliz. Ese costo solo se justifica si evitas algo caro —correr la suite pesada cuando smoke ya falló—. Con todo en centésimas de segundo, no hay nada caro que evitar: correr smoke y la suite completa en paralelo termina antes, y el escalonado solo suma la espera de un job por el otro sin comprar ningún ahorro. Para una suite trivialmente rápida, el paralelo es mejor.

Si Reservo tuviera una matriz de 10 minutos (módulo 4), la respuesta se invierte: el escalonado gana. Ahora sí hay algo caro que proteger. Si alguien rompe el descuento pro, la puerta smoke lo caza en 0.02 s; escalonada con needs: smoke, la matriz de 10 minutos nunca arranca, ahorrando esos 10 minutos (× las celdas de la matriz, × cada push roto). El costo del escalonado —esperar 0.02 s a que smoke pase en el caso feliz— es despreciable frente a los 10 minutos que ahorras en el caso roto. La regla (lección 5): escalona cuando lo que va detrás de la puerta es caro y los fallos que la puerta atrapa son frecuentes; corre en paralelo cuando todo es barato. El número decide, no el reflejo de "escalonar siempre es mejor". Para Reservo hoy: paralelo. Para Reservo con matriz grande: escalonado.

Resumen y siguiente paso

En este mini-proyecto pusiste una puerta de calidad al CI de Reservo de principio a fin, y —el requisito clave— demostraste que rompe el build cuando falta un test. Entregaste el workflow YAML con la puerta de cobertura (--cov-fail-under=91, un trinquete honesto) y la puerta smoke escalonada (needs: smoke); el .coveragerc con source = reservo que hace honesta la medición; la demostración local del ciclo rojo→verde —la puerta rompiendo el build con cancel_with_refund sin probar (65.38%, exit 1), tú agregando el test que verifica, y la puerta pasando (91.03%, exit 0)—; la puerta smoke mordiendo el bug del descuento pro (assert 5625 == 6000, exit 1); y la nota de decisión que justifica el 91 —trinquete contra la caída, no el 80 que la dejaría pasar, no el 100% que empujaría tautologías—.

Esto cierra el módulo. Ahora sabes convertir un CI que mide en uno que impone: la diferencia entre medir e imponer calidad, cómo --cov-fail-under la implementa con su exit code, cómo un trinquete atrapa las caídas que un piso ignora, cómo una puerta smoke protege lo crítico, cuándo una puerta ayuda y cuándo estorba, y por qué el 100% como fetiche produce tests que suben la cobertura sin atrapar bugs. Tu pipeline ya no solo te dice cómo está la calidad; la exige.

Lo que sigue, en el módulo 7, es el fantasma que acecha a todo lo que construiste: los tests flaky. Una puerta de calidad exige que ciertos tests pasen —los smoke, la suite entera para la cobertura—. Pero ¿qué pasa cuando un test pasa a veces y falla a veces sin que cambie el código? Ese test no determinista rompe la puerta de forma aleatoria, erosiona la confianza en el CI (el equipo empieza a re-correr los builds "a ver si ahora sí pasa"), y merece un tratamiento propio: el debate del retry, la cuarentena, y el fallo que solo ocurre en CI. Cerraste las puertas que imponen calidad; el módulo 7 se ocupa de lo que las hace poco fiables cuando no deberían.

Recursos