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

3. `--cov-fail-under` y el exit code

Descripción

Este es el corazón del módulo. En las dos lecciones anteriores instalaste la idea —una puerta de calidad es métrica, umbral y consecuencia, y la consecuencia vive en el exit code—. Ahora la vas a ejecutar de principio a fin, con tus manos, sobre la suite de Reservo: la puerta rompiendo el build cuando falta un test, tú escribiendo el test que falta, y la misma puerta pasando a verde. No un concepto: un ciclo completo de rojo a verde, con salidas reales y exit codes reales, que es exactamente lo que harías para poner esta puerta en un proyecto de verdad.

Al terminar vas a saber usar pytest --cov=reservo --cov-fail-under=80 con precisión: qué hace --cov=reservo (medir la cobertura del paquete), qué hace --cov-fail-under=80 (poner el umbral que rompe el build), y cómo leer el exit code que emite —1 cuando la cobertura está por debajo, 0 cuando la alcanza—. Vas a ver el ciclo real: con cancel_with_refund sin probar, la cobertura es 65.38% y la puerta rompe el build; agregas el test, la cobertura sube a 91.03%, y la puerta pasa. Y vas a conocer al primo del comando, el CLI de coveragecoverage run seguido de coverage report --fail-under=80—, con su exit code distinto (2 en vez de 1) y el archivo .coveragerc que define qué se mide, una pieza que vas a necesitar para que la puerta no mienta.

Conexión con el módulo: esta lección convierte la teoría de la 2 en músculo. La 1 te mostró la puerta romper el build; la 2 la definió por sus tres partes; esta la ejecuta entera, ida y vuelta. Y prepara las dos que siguen: la 4 parte de esta misma puerta de piso fijo para mostrar su punto ciego (una caída que no cruza el piso), y la 5 reusa la mecánica del exit code con otra métrica (los marcadores smoke). Si dominas el ciclo rojo→verde de esta lección, las demás son variaciones. Por eso aquí vamos despacio y ejecutamos de verdad cada paso.

Apagar la alarma arreglando el fuego, no desconectando el sensor

Cuando el detector de humo de tu casa se dispara mientras cocinas, hay dos formas de callarlo. La primera, la correcta: apagar el fuego o ventilar el humo —resolver la causa—. La segunda, la tentadora y peligrosa: arrancar la batería del detector para que deje de pitar. Las dos silencian la alarma. Pero una resuelve el problema y la otra solo apaga la señal, dejando tu casa sin protección para el próximo incendio de verdad.

Una puerta de cobertura que rompe el build es esa alarma. Cuando se dispara —"cobertura por debajo de 80%"— tienes las mismas dos opciones. La correcta: escribir los tests que faltan, subir la cobertura de verdad, y ver la puerta pasar porque el problema se resolvió. La tentadora: bajar el umbral (--cov-fail-under=60) o borrar el código sin probar para que el número suba sin escribir un test —arrancarle la batería al detector—. Las dos ponen el build en verde. Pero una cierra el hueco de código sin probar y la otra solo esconde que existe.

Esta lección te enseña a apagar el fuego, no a desconectar el sensor. El ciclo que vas a ejecutar —puerta roja, escribo el test, puerta verde— es la respuesta correcta hecha rutina. Y en las lecciones 6 y 7 vas a ver, con nombre y apellido, las formas de "arrancar la batería" (el umbral fetiche, los tests tautológicos) y por qué hacen más daño que dejar la alarma sonando. Por ahora, la imagen: la puerta no es tu enemiga cuando se pone roja; es el detector haciendo su trabajo, señalándote un código que dejaste sin cubrir.

Cuando la puerta de cobertura rompe el build, la respuesta correcta es escribir el test que falta —apagar el fuego—, no bajar el umbral ni borrar el código sin probar —arrancarle la batería al detector—. Las dos ponen el build en verde; solo una cierra el hueco.

Anatomía del comando: --cov y --cov-fail-under

Antes de correrlo, desarmemos el comando pieza por pieza, porque cada bandera hace una cosa distinta y conviene no confundirlas.

python -m pytest --cov=reservo --cov-fail-under=80
  • python -m pytest — corre la suite, como siempre. Esta parte descubre y ejecuta los tests de Reservo; sin las otras banderas, es tu pipeline de toda la vida.
  • --cov=reservo — enciende la medición de cobertura y le dice qué medir: el paquete reservo. Esto es lo que hace pytest-cov (el plugin) mientras la suite corre: observa qué líneas del paquete reservo se ejecutan y cuáles no. Es la métrica de la puerta. Sin esta bandera, no hay cobertura que reportar. Nota el detalle: medimos reservo (el código), no tests (los tests) —queremos saber cuánto del código ejercitan los tests, no cuánto de los tests corrieron—.
  • --cov-fail-under=80 — pone el umbral y arma la consecuencia: "si la cobertura total del código medido queda por debajo de 80%, termina con un exit code de fallo". Esta es la bandera que convierte la medición en puerta. Sin ella, --cov=reservo solo reporta (lo viste en la lección 2: exit 0 aunque la cobertura sea baja); con ella, impone.

Las tres partes de la lección 2, en una línea: métrica (--cov=reservo), umbral (80), consecuencia (el exit code que --cov-fail-under emite). Ahora corrámoslo.

El ciclo, paso 1: la puerta rompe el build

Empezamos con la suite en el estado que abrió el módulo: Reservo estrenó cancel_with_refund, pero llegó sin tests. La suite tiene diez tests —tres de precios, tres de reembolsos, cuatro de disponibilidad— y todos pasan. Corremos la puerta, de verdad, en Python 3.14.0 con pytest 9.1.1, coverage 7.15.2 y pytest-cov 7.1.0. Le agregamos --cov-report=term-missing, que además del porcentaje imprime qué líneas quedaron sin cubrir —el mapa exacto del hueco—:

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

Qué esperar (salida real, medida ejecutando):

============================= 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   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 80% not reached. Total coverage: 65.38%
============================== 10 passed in 0.03s ==============================

Léelo por capas. Arriba, 10 passed: los diez tests pasan. La tabla de cobertura te dice por qué la puerta se queja de todas formas. Mira la fila reservo/cancellations.py 17 17 0% 7-36: tiene 17 líneas de código (Stmts), las 17 sin ejecutar (Miss), 0% de cobertura, y las líneas sin cubrir son de la 7 a la 36 —el archivo entero—. Ahí está cancel_with_refund y refund_reason, código real que ningún test toca. La fila reservo/calendar.py está en 77%: le faltan las líneas 24, 26, 34, 49-51, 55 —las ramas de is_available que ningún test recorre, el raise de book, y el método cancel, que solo se llamaba desde la cancelación no probada—. Sumando todo el paquete: TOTAL 78 27 65% —78 líneas, 27 sin cubrir, 65.38%—.

Y abajo, la puerta: FAIL Required test coverage of 80% not reached. Total coverage: 65.38%, con su eco arriba, ERROR: Coverage failure: total of 65 is less than fail-under=80. La suite pasó, pero la puerta no. Confirmemos que el build de verdad se rompió, mirando el único dato 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. En el runner, este 1 pintaría el step de rojo y detendría el merge —igual que un test fallido, aunque aquí los diez tests pasaron—. La puerta hizo su trabajo: detectó que un tercio del código no está probado y bloqueó el cambio. Ahora, a apagar el fuego.

El ciclo, paso 2: escribo el test que falta

La respuesta correcta no es bajar el umbral; es probar cancel_with_refund. Escribimos un archivo de tests para la cancelación que ejercite la función de verdad —cancelar una reserva, confirmar el reembolso, y verificar que el intento de cancelar dos veces falla—:

# tests/test_cancellations.py
from datetime import datetime

from reservo.calendar import Calendar
from reservo.cancellations import cancel_with_refund, refund_reason
from reservo.models import Member, Room

focus = Room(id="r-focus", name="Focus", capacity=1, hourly_cents=2500)
ana = Member(id="m-1", name="Ana", tier="basic")


def _confirmed_booking(cal, start, end):
    return cal.book(focus, ana, start, end)


def test_cancel_with_full_refund_frees_the_slot():
    cal = Calendar()
    booking = _confirmed_booking(cal, datetime(2026, 1, 4, 9), datetime(2026, 1, 4, 12))
    # reserva basic 3h = 7500; cancelar 72h antes -> 100% de vuelta
    now = datetime(2026, 1, 1, 9)
    refund = cancel_with_refund(cal, booking, now)
    assert refund == 7500
    assert booking.status == "cancelled"
    # el horario queda libre otra vez despues de cancelar
    assert cal.is_available(focus, datetime(2026, 1, 4, 10), datetime(2026, 1, 4, 11)) is True


def test_cancel_twice_raises():
    cal = Calendar()
    booking = _confirmed_booking(cal, datetime(2026, 1, 4, 9), datetime(2026, 1, 4, 12))
    now = datetime(2026, 1, 1, 9)
    cancel_with_refund(cal, booking, now)
    try:
        cancel_with_refund(cal, booking, now)
        assert False, "expected ValueError on double cancel"
    except ValueError:
        pass


def test_refund_reason_text():
    booking = _confirmed_booking(Calendar(), datetime(2026, 1, 4, 9), datetime(2026, 1, 4, 12))
    assert "full refund" in refund_reason(booking, datetime(2026, 1, 1, 9))
    assert "half refund" in refund_reason(booking, datetime(2026, 1, 2, 21))
    assert "no refund" in refund_reason(booking, datetime(2026, 1, 4, 0))

Fíjate en que estos no son tests de relleno: verifican comportamiento real. test_cancel_with_full_refund_frees_the_slot confirma el número-ancla del reembolso (7500, el 100% de una reserva basic de 3 h), que la reserva quedó en cancelled, y que su horario se liberó. test_cancel_twice_raises confirma que cancelar dos veces es un error. test_refund_reason_text recorre los tres tramos del reembolso. Son tests que afirman algo, no solo que tocan el código —la distinción que la lección 7 vuelve central—. Corramos la puerta otra vez.

El ciclo, paso 3: la puerta pasa

Mismo comando, ahora con el test agregado:

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

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 13 items

tests/test_availability.py ....                                          [ 30%]
tests/test_cancellations.py ...                                          [ 53%]
tests/test_pricing.py ...                                                [ 76%]
tests/test_refunds.py ...                                                [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      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 80% reached. Total coverage: 91.03%
============================== 13 passed in 0.03s ==============================

Mira lo que cambió. collected 13 items —los tres tests nuevos—, todos verdes. La fila reservo/cancellations.py pasó de 0% a 100%: sus 17 líneas ahora las ejercita el test. reservo/calendar.py subió de 77% a 87%, porque el test de cancelación también recorre calendar.cancel y is_available con una reserva cancelada de por medio. El total saltó de 65.38% a TOTAL 78 7 91% —91.03%—. Y abajo, la puerta cambió de tono: Required test coverage of 80% reached. Total coverage: 91.03%. Sin FAIL, sin ERROR. Confirmemos 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 en el runner. Y —esto es lo importante— no lo logramos bajando el umbral ni borrando código: lo logramos probando el código que estaba sin probar. Apagamos el fuego. El ciclo completo —puerta roja (exit 1, 65.38%), escribo el test, puerta verde (exit 0, 91.03%)— es la rutina que este módulo te enseña a hacer natural. Y fíjate que aún quedan líneas sin cubrir (calendar.py en 87%, models.py en 83%): la puerta no exige el 100%, exige pasar de 80. Ese margen entre "suficiente" y "perfecto" es precisamente el tema de las lecciones 6 y 7.

El primo del CLI: coverage report --fail-under y el .coveragerc

pytest --cov-fail-under es la forma más común de poner la puerta, pero hay una segunda, más explícita, que conviene conocer porque a veces la verás en pipelines y porque separa las dos fases —medir y evaluar— en dos comandos. Es el CLI de la herramienta coverage (de la que pytest-cov se apoya por dentro):

  1. coverage run -m pytest — corre la suite midiendo la cobertura, y guarda los datos en un archivo .coverage. No evalúa nada todavía; solo mide.
  2. coverage report --fail-under=80 — lee esos datos, imprime el reporte, y aplica la puerta: si el total está por debajo de 80, termina con exit code de fallo.

Pero hay un detalle que muerde si lo ignoras: por defecto, coverage run mide todo lo que se ejecuta, incluidos los archivos de test, e ignora los archivos que nunca se importaron. Eso es un problema doble: infla el número (los tests siempre están 100% "cubiertos", porque corrieron) y, peor, cancellations.py desaparecería del reporte —como ningún test lo importa, coverage ni lo ve, y no puede reportar un 0% de un archivo que no sabe que existe—. Una puerta que no ve el código sin probar no protege de nada.

La solución es un archivo de configuración, .coveragerc, que le dice a coverage exactamente qué medir:

# .coveragerc
[run]
source = reservo

[report]
show_missing = True

source = reservo hace dos cosas: limita la medición al paquete reservo (fuera los archivos de test) y —clave— le dice a coverage que descubra todos los archivos del paquete, aunque no se hayan importado, y los cuente como 0% si nadie los tocó. Así cancellations.py sin tests aparece con su 0% honesto. show_missing = True agrega la columna de líneas sin cubrir, como term-missing. Con este .coveragerc en su lugar, corremos el CLI sobre la suite incompleta (sin el test de cancelación):

coverage run -m pytest
coverage report --fail-under=80

Qué esperar (salida real):

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%
Coverage failure: total of 65 is less than fail-under=80

Mismo 65.38%, misma tabla, mismo cancellations.py en 0% —porque source = reservo lo hizo visible—. Y la puerta falla con su mensaje: Coverage failure: total of 65 is less than fail-under=80. Pero mira el exit code, que es distinto:

coverage report --fail-under=80 > /dev/null 2>&1; echo "exit code: $?"
exit code: 2

Exit code 2, no 1. Aquí está el matiz que anticipó la lección 2: el CLI de coverage usa exit code 2 para "no se alcanzó fail_under", mientras que pytest-cov usa 1. Para el CI da igual —cualquier número distinto de cero es rojo—, pero para ti el número cuenta una historia: un 2 te dice "fue la puerta de cobertura del CLI", mientras que un 1 de pytest podría ser "la puerta o un test que falló". Cuando agregas el test de cancelación y vuelves a correr coverage run -m pytest && coverage report --fail-under=80, el total sube a 91% y el exit code baja a 0 —la puerta pasa—, igual que con pytest-cov.

¿Cuál usar? Para la mayoría de los proyectos, pytest --cov=reservo --cov-fail-under=80 en un solo comando es más simple y es lo que pondremos en el workflow del mini-proyecto. El CLI de dos pasos brilla cuando quieres separar la medición de la evaluación —por ejemplo, medir en varios jobs de una matriz y combinar los datos antes de aplicar la puerta una sola vez—, un caso avanzado que solo mencionamos. Lo importante es que reconozcas los dos, y sobre todo que entiendas el .coveragerc, porque source = reservo es lo que evita que la puerta se ciegue justo al código sin probar que debe cazar.

Errores comunes

Bajar el umbral para que la puerta pase (arrancarle la batería al detector). Qué pasa: la puerta rompe el build en 65.38%, y alguien "resuelve" el rojo cambiando --cov-fail-under=80 a --cov-fail-under=60. El build vuelve a verde, pero el código sin probar sigue exactamente igual de sin probar. Por qué pasa: bajar un número es más rápido que escribir un test, y el build verde da la falsa sensación de haber resuelto algo. Cómo detectarlo: si la cobertura real no subió pero la puerta pasó, alguien movió el umbral, no escribió tests. Revisa el historial del --cov-fail-under. Cómo corregirlo: trata el umbral como un piso que solo sube (el trinquete de la lección 4). La puerta roja es una invitación a escribir el test que falta, no a rebajar la exigencia. Bajar el umbral es legítimo solo como decisión explícita y justificada del equipo, nunca como reflejo para acallar un rojo.

Medir tests en vez de reservo, o no scopear con .coveragerc. Qué pasa: alguien corre coverage run -m pytest sin .coveragerc (o pone --cov=.), y el reporte incluye los archivos de test —siempre "100% cubiertos"— e ignora cancellations.py, que nunca se importó. El número sale inflado y el hueco real, invisible. Por qué pasa: por defecto coverage mide lo que se ejecuta, y los tests se ejecutan, así que "cuentan". Cómo detectarlo: si tu reporte tiene filas test_*.py al 100% y no ves los módulos sin probar, estás midiendo lo equivocado. Cómo corregirlo: source = reservo en .coveragerc (o --cov=reservo con pytest-cov) para medir el código, no los tests, y para que los módulos no importados aparezcan con su 0% honesto. La puerta debe ver justo el código que nadie probó; scopearla mal la ciega.

Leer el reporte y no el exit code. Qué pasa: alguien ve el reporte de cobertura impreso, con su tabla y su porcentaje, y asume que la puerta "está funcionando", sin comprobar que el comando termina con exit code distinto de cero cuando debe. Por qué pasa: el reporte es lo visible; el exit code hay que pedirlo con echo $?. Cómo detectarlo: corre la puerta con la cobertura por debajo del umbral y confirma el exit code (1 para pytest-cov, 2 para el CLI). Si es 0, tienes reporte pero no puerta. Cómo corregirlo: comprueba siempre que la puerta muerde —igual que compruebas que un test muerde rompiendo el código—, poniendo la cobertura bajo el umbral a propósito y viendo el exit code de fallo. El CI lee el exit code, no la tabla.

Ejercicios

Ejercicio 1 — Predice el exit code. Para cada comando sobre Reservo, predice el exit code (0 o distinto de cero, y cuál) y explica por qué en una frase. Supón la cobertura real en cada estado. (a) Suite incompleta (65.38%): pytest --cov=reservo --cov-fail-under=80. (b) Suite completa (91.03%): pytest --cov=reservo --cov-fail-under=80. (c) Suite incompleta (65.38%): pytest --cov=reservo (sin --cov-fail-under). (d) Suite incompleta (65.38%): coverage report --fail-under=80 (con datos de una corrida previa).

Ver solución
  • (a) Exit 1. La cobertura (65.38%) está por debajo del umbral (80), así que pytest-cov rompe el build con su exit code de fallo, 1. Los tests pasan, pero la puerta no.
  • (b) Exit 0. La cobertura (91.03%) alcanza el umbral (80 ≤ 91), así que la puerta pasa y, como los tests también pasan, el exit code es 0. Verde.
  • (c) Exit 0. Sin --cov-fail-under no hay umbral, así que no hay puerta: --cov=reservo solo reporta el 65.38% pero no lo impone. Como los tests pasan, exit 0. (Es el letrero de la lección 2: mide pero no muerde.)
  • (d) Exit 2. El CLI de coverage usa exit code 2 para "no se alcanzó fail_under", distinto del 1 de pytest-cov. La cobertura (65.38%) está bajo 80, así que coverage report --fail-under=80 termina en 2.

La lección de (c) frente a (a): el mismo 65.38% da exit 0 o exit 1 según haya o no umbral —la puerta vive en --cov-fail-under, no en --cov—. La de (a) frente a (d): la misma cobertura por debajo del umbral da exit 1 con pytest-cov y exit 2 con el CLI —el número de fallo depende de la herramienta, pero ambos son "rojo" para el CI—.

Ejercicio 2 — Escribe el test que apaga el fuego. Reservo tiene otra función sin probar: refund_reason(booking, now), que devuelve el texto del reembolso ("full refund", "half refund", "no refund") según cuántas horas antes se cancela. La puerta está roja porque esta función baja la cobertura. Escribe un test que la ejercite en sus tres tramos (≥ 48 h, 24–48 h, < 24 h) y explica por qué escribir el test es mejor respuesta que bajar el umbral.

Ver solución

Un test que recorre los tres tramos, usando fechas que caen en cada rango respecto a un start fijo:

from datetime import datetime

from reservo.calendar import Calendar
from reservo.cancellations import refund_reason
from reservo.models import Member, Room

focus = Room(id="r-focus", name="Focus", capacity=1, hourly_cents=2500)
ana = Member(id="m-1", name="Ana", tier="basic")


def test_refund_reason_covers_the_three_tiers():
    booking = Calendar().book(focus, ana, datetime(2026, 1, 4, 9), datetime(2026, 1, 4, 12))
    # >= 48h antes -> full
    assert "full refund" in refund_reason(booking, datetime(2026, 1, 1, 9))    # 72h antes
    # 24-48h antes -> half
    assert "half refund" in refund_reason(booking, datetime(2026, 1, 2, 21))   # 36h antes
    # < 24h antes -> none
    assert "no refund" in refund_reason(booking, datetime(2026, 1, 4, 0))      # 9h antes

Por qué el test es mejor que bajar el umbral: refund_reason decide el texto que le llega al cliente en el correo de cancelación —un texto equivocado ("full refund" cuando en realidad no hay reembolso) es un error que le costaría dinero o confianza a Reservo—. Bajar el umbral (--cov-fail-under=70) pondría el build en verde pero dejaría esa función sin ninguna red: el día que alguien invierta una condición, nada lo atraparía. El test, en cambio, cierra el hueco de verdad: ahora, si refund_reason se rompe, un test se pone rojo. Bajar el umbral apaga la alarma; el test apaga el fuego. Además, este test verifica (afirma el texto correcto en cada tramo), no solo ejecuta la función —es la clase de test que la lección 7 defiende frente a los tautológicos—.

Ejercicio 3 — Diagnostica el .coveragerc que miente. Un compañero pone una puerta con el CLI de coverage, pero sin .coveragerc. Corre coverage run -m pytest y coverage report --fail-under=80, y le da exit 0 —la puerta pasa— aunque cancellations.py no tiene ni un test. ¿Por qué la puerta no lo atrapó, y cómo lo arreglas?

Ver solución

La puerta pasó porque, sin .coveragerc, coverage midió lo equivocado de dos formas:

  1. Incluyó los archivos de test en el cálculo. Los test_*.py se ejecutaron, así que coverage los cuenta como 100% cubiertos. Eso infla el total: si sumas al denominador un montón de líneas de test siempre "cubiertas", el porcentaje sube artificialmente por encima de 80, aunque el código esté mal cubierto.
  2. No vio cancellations.py. Como ningún test lo importa, ese archivo nunca se ejecutó, y coverage —sin source = reservo— ni siquiera sabe que existe. No puede reportar el 0% de un archivo que no descubrió. El hueco más grande quedó invisible.

Entre las dos, el total supera el 80% falso y la puerta pasa: una puerta ciega justo al código que debía cazar.

El arreglo es el .coveragerc con source = reservo:

[run]
source = reservo

[report]
show_missing = True

source = reservo limita la medición al paquete reservo (fuera los tests, que dejan de inflar) y hace que coverage descubra todos los archivos del paquete, importados o no —así cancellations.py aparece con su 0% honesto y arrastra el total hacia abajo—. Con esto, la misma corrida da 65.38% y exit 2 (la puerta atrapa el hueco). La lección: una puerta de cobertura vale lo que vale su configuración de qué medir; scopearla mal es tan peligroso como no tenerla, porque da una falsa sensación de protección.

Resumen y siguiente paso

En esta lección ejecutaste el corazón del módulo: el ciclo completo de una puerta de cobertura sobre Reservo, de rojo a verde, con salidas y exit codes reales. Desarmaste el comando —--cov=reservo mide (métrica), --cov-fail-under=80 impone (umbral + consecuencia)— y viste las tres fases: la puerta rompiendo el build con cancel_with_refund sin probar (65.38%, Coverage failure: total of 65 is less than fail-under=80, exit 1); tú escribiendo el test que faltaba —uno que verifica, no solo ejecuta—; y la misma puerta pasando (91.03%, Required test coverage of 80% reached, exit 0). La respuesta correcta al rojo fue apagar el fuego (escribir el test), no arrancarle la batería al detector (bajar el umbral).

También conociste al primo del CLI: coverage run -m pytest seguido de coverage report --fail-under=80, con su exit code 2 (en vez del 1 de pytest-cov) y —lo más importante— el .coveragerc con source = reservo, que evita que la puerta se ciegue midiendo los tests e ignorando el código nunca importado. Una puerta vale lo que vale su configuración de qué medir.

Antes de avanzar deberías poder: escribir pytest --cov=reservo --cov-fail-under=80 y explicar qué hace cada bandera; leer un reporte term-missing y localizar el archivo que hunde la cobertura; predecir el exit code de una puerta según la cobertura y la herramienta (1 pytest-cov, 2 CLI, 0 si pasa); y explicar por qué source = reservo en .coveragerc es lo que hace honesta a la puerta.

Lo que sigue, en la lección 4, es un punto ciego de esta misma puerta. El piso fijo de 80 protege de que la cobertura caiga por debajo de 80, pero no de que caiga estando por encima: si tu cobertura es 91% y alguien agrega código sin tests que la baja a 84%, la puerta de piso 80 pasa —84 > 80— y no ve la caída de siete puntos. La lección 4 muestra ese hueco con una demo real y cómo cerrarlo con una puerta que falla en la caída de cobertura, no solo contra un piso lejano: el trinquete que solo sube.

Recursos