Cobertura y CI — ¿qué líneas nunca ejecutaron tus tests?
Medir la cobertura con pytest-cov, leer la columna Missing, cobertura de ramas, hacer cumplir un mínimo, ejecutar tests en paralelo con pytest-xdist y ejecutar toda la suite en cada push con GitHub Actions. Y por qué 100% de cobertura no significa código correcto.
¿Cómo quieres aprender?
El problema que vamos a resolver
Esta es una función pequeña que calcula el precio de un paquete, y dos tests que la comprueban.
shop/shipping.py:
def shipping_cost(weight_kg: float, country: str) -> float:
if weight_kg <= 0:
raise ValueError(f"weight must be positive: {weight_kg}")
if country == "BD":
base = 60.0
elif country == "IN":
base = 90.0
else:
base = 500.0
if weight_kg > 5:
base += (weight_kg - 5) * 20
return basetests/test_shipping.py:
from shop.shipping import shipping_cost
def test_light_parcel_to_bangladesh():
assert shipping_cost(1, "BD") == 60.0
def test_heavy_parcel_to_bangladesh():
assert shipping_cost(7, "BD") == 100.0$ pytest -q
.. [100%]
2 passed in 0.07sEn verde. Y el verde no dice nada sobre las partes de la función que los tests nunca tocaron. ¿Un paquete a India cuesta 90? ¿Un peso de cero lanza realmente una excepción? Nadie lo preguntó. Una errata en la rama "IN" se quedaría ahí, pasando, hasta que un cliente la encontrara.
Con doce líneas puedes comprobarlo a ojo. Con doce mil no. Necesitas que la computadora responda una pregunta: ¿qué líneas mis tests nunca ejecutaron? Esa medición se llama cobertura, y este capítulo trata de obtenerla, leerla y no dejarse engañar por ella —y después, de ejecutar toda la suite de forma rápida y automática en cada push—.
Al terminar este capítulo podrás
- Medir la cobertura con
pytest-covy leer la columnaMissing - Abrir el informe HTML para ver las líneas sin cubrir en su contexto
- Explicar por qué la cobertura de líneas puede decir 100% aunque una rama nunca se tomara, y activar la cobertura de ramas
- Hacer fallar la ejecución cuando la cobertura baje de un umbral, y excluir código a propósito
- Guardar todo esto en
pyproject.toml - Explicar por qué 100% de cobertura no significa que el código sea correcto
- Ejecutar tests en paralelo con
pytest-xdisty explicar por qué los tests no deben depender unos de otros - Escribir un workflow de GitHub Actions que ejecute la suite en varias versiones de Python
Requisitos previos: configuration and warnings.
Antes de escribir el test
La forma equivocada de usar la cobertura es abrir el informe primero y escribir cualquier test que vuelva verde la siguiente línea roja. Eso produce tests con la forma del código, no la de la promesa que el código hace —y, como verás, deja pasar errores que no tienen una línea propia—. El orden correcto es: planificar a partir del contrato, escribir los tests y solo entonces preguntarle a la cobertura qué olvidó el plan.
El contrato. Lee shipping_cost como una promesa, no como código:
- peso cero o menor → un
ValueErrorcuyo mensaje dice "positive" - Bangladés cuesta 60, India 90, cualquier otro lugar 500
- por encima de 5 kg, cada kilogramo extra suma 20; con exactamente 5 kg no se añade nada
- sin efectos secundarios: devuelve un número y no toca nada más
Qué debe estar listo. Un entorno virtual con pytest y pytest-cov instalados (y pytest-xdist más adelante). El paquete debe ser importable desde los tests —aquí shop, ya sea instalado o alcanzado mediante pythonpath = ["src"] del capítulo anterior—. Y necesitas conocer el nombre de import del código que estás midiendo, porque eso es lo que va después de --cov=.
El plan. Una fila por comportamiento, con fronteras y entradas inválidas a propósito:
| Caso | Entrada | Esperado | |---|---|---| | camino feliz, país de origen | 1, "BD" | 60.0 | | segundo país | 1, "IN" | 90.0 | | cualquier otro lugar | 1, "US" | 500.0 | | frontera: exactamente 5 kg | 5, "BD" | 60.0 (sin recargo) | | por encima del límite | 7, "BD" | 100.0 (60 + 2 × 20) | | inválido: peso cero | 0, "BD" | ValueError, "positive" |
Qué no probar. Código que no escribiste (el round de Python, la propia biblioteca coverage). Un bloque __main__ que solo se ejecuta cuando una persona ejecuta el archivo a mano. Y nunca un test cuyo único trabajo sea hacer que una línea se ejecute —un test sin un assert con significado sube el porcentaje y no comprueba nada—.
Fíjate en las dos filas de frontera. Un test con 5 kg no ejecuta ninguna línea que el test de 1 kg no ejecute ya, así que ningún informe de cobertura lo pedirá jamás. Y para el caso inválido, un test con -1 cubriría la línea del raise exactamente igual que 0 —pero si alguien escribiera weight_kg < 0 en vez de <= 0, solo el test con 0 fallaría. La cobertura no puede distinguir esos dos tests; el contrato sí. Esa es toda la relación: el plan decide qué probar; la cobertura informa de lo que el plan olvidó.
Los dos tests del principio cubren las filas 1 y 5. El resto del capítulo mide esa brecha, la cierra y después automatiza la medición.
Midiendo con pytest-cov
La cobertura la mide la biblioteca coverage; pytest-cov es el plugin que la activa desde la línea de comandos de pytest. Instálalo como dependencia de desarrollo:
$ uv add --dev pytest-covLuego indícale qué paquete vigilar con --cov=:
$ pytest -q --cov=shop
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover
--------------------------------------
shop/__init__.py 0 0 100%
shop/shipping.py 11 4 64%
--------------------------------------
TOTAL 11 4 64%
2 passed in 0.09sStmts es el número de sentencias ejecutables del archivo. Miss es cuántas de ellas nunca se ejecutaron. Cover es la proporción que sí lo hizo. Once sentencias, cuatro nunca ejecutadas: 64%.
El valor después de --cov= es el código que quieres medir, no los tests. Tus tests siempre ejecutan todas sus propias líneas, así que contarlas solo inflaría el total.
Leyendo la columna Missing
Un porcentaje te dice cuánto. No te dice dónde. Añade --cov-report=term-missing:
$ pytest -q --cov=shop --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------
shop/__init__.py 0 0 100%
shop/shipping.py 11 4 64% 3, 6-9
------------------------------------------------
TOTAL 11 4 64%
2 passed in 0.09s3, 6-9 son números de línea en shipping.py. Cuenta hacia abajo por el archivo:
- La línea 3 es
raise ValueError(...)—ningún test pasó un peso de cero o menos—. - Las líneas 6 a 9 son
elif country == "IN":,base = 90.0,else:ybase = 500.0—ningún test envió un paquete a otro sitio que no fuera Bangladés—.
La línea 8, else:, no es una sentencia por sí misma, así que no entra en el conteo de once; la cobertura simplemente la pliega en el rango 6-9 para mantener la lista corta.
Esa columna es la lista de tareas pendientes. Cada número es una línea que podría estar completamente mal y tu suite seguiría en verde.
El informe HTML
Para un archivo grande, una lista de números es difícil de seguir. --cov-report=html escribe en su lugar una web pequeña:
$ pytest -q --cov=shop --cov-report=html
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Coverage HTML written to dir htmlcov
2 passed in 0.11sAbre htmlcov/index.html en un navegador. Cada archivo aparece con su porcentaje; haz clic en uno y ves el código fuente, con las líneas ejecutadas marcadas en verde y las no ejecutadas en rojo. Es la misma información que Missing, superpuesta sobre el propio código.
Tanto htmlcov/ como el archivo de datos .coverage son salida generada. Ponlos en .gitignore.
Cobertura de ramas: por qué la cobertura de líneas miente
Esta es una función en la que la cobertura de líneas dice que todo está bien:
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)from shop.coupons import final_price
def test_coupon_takes_ten_percent_off():
assert final_price(200.0, "SAVE10") == 180.0$ pytest -q --cov=shop --cov-report=term-missing
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------
shop/__init__.py 0 0 100%
shop/coupons.py 4 0 100%
------------------------------------------------
TOTAL 4 0 100%
1 passed in 0.09s100%. Cada línea se ejecutó. Pero nadie llamó nunca a final_price sin un cupón. Un if sin else tiene dos salidas —al cuerpo, o pasando de largo—, y solo se probó una. Si alguien rompe después el camino sin cupón, este informe no lo notará.
La cobertura de líneas pregunta «¿esta línea se ejecutó?». La cobertura de ramas pregunta «¿cada decisión fue en ambas direcciones?». Actívala con --cov-branch:
$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 1 83% 2->4
--------------------------------------------------------------
TOTAL 4 0 2 1 83%
1 passed in 0.10sDos columnas nuevas. Branch es el número de saltos posibles (el if de la línea 2 puede ir a la línea 3 o a la línea 4: dos). BrPart cuenta las decisiones que solo fueron en una dirección. Y Missing ahora muestra 2->4: el salto de la línea 2 directamente a la línea 4 —el camino "sin cupón"— nunca ocurrió.
La cobertura de ramas es más estricta y se acerca más a la verdad. Hay pocas razones para no tenerla activada todo el tiempo.
Convertir la cifra en una puerta: --cov-fail-under
Un informe que nadie lee no cambia nada. --cov-fail-under convierte el total en una condición de paso o fallo:
$ pytest -q --cov=shop --cov-branch --cov-report=term-missing --cov-fail-under=90
.
ERROR: Coverage failure: total of 83 is less than fail-under=90
[100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 1 83% 2->4
--------------------------------------------------------------
TOTAL 4 0 2 1 83%
FAIL Required test coverage of 90% not reached. Total coverage: 83.33%
1 passed in 0.12sLee la última línea con atención: 1 passed. Cada test pasó, y sin embargo el comando sale con estado 1. En CI eso es un build en rojo. El punto no es que 90 sea un número mágico; el punto es que la cobertura ya no puede bajar en silencio cuando alguien añade código sin tests.
Elige un umbral un poco por debajo de donde estás hoy y súbelo con el tiempo. Ponerlo en 100 el primer día suele terminar con gente escribiendo tests sin sentido para satisfacer la cifra.
Excluir código a propósito: # pragma: no cover
Hay líneas que no merece la pena probar —por ejemplo, un bloque que solo se ejecuta cuando ejecutas el archivo a mano—. Añade un segundo test, assert final_price(200.0, None) == 200.0, para que final_price quede totalmente cubierto —y después un bloque __main__ al módulo:
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)
if __name__ == "__main__":
print(final_price(200.0, "SAVE10"))$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 6 1 4 1 80% 8
--------------------------------------------------------------
TOTAL 6 1 4 1 80%
2 passed in 0.11sLa línea 8, el print, nunca se ejecuta bajo pytest —y nunca lo hará—. Marca el if con un comentario que la cobertura entienda:
if __name__ == "__main__": # pragma: no cover
print(final_price(200.0, "SAVE10"))$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 0 100%
--------------------------------------------------------------
TOTAL 4 0 2 0 100%
2 passed in 0.08sCuando el pragma está en una línea que abre un bloque, todo el bloque se excluye. Úsalo con moderación y con honestidad. Cada pragma es una línea que has decidido no comprobar; si te encuentras añadiendo uno para que una rama difícil «desaparezca», esa rama probablemente es la que más necesita un test.
Ponerlo en pyproject.toml
Escribir cuatro flags --cov cada vez es la forma en que se acaban olvidando. La cobertura lee sus propios ajustes de pyproject.toml, y el addopts que conociste en el capítulo anterior hace que pytest la active:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = true
[tool.coverage.report]
fail_under = 90
exclude_also = [
'if __name__ == "__main__":',
][tool.coverage.run]controla la medición:sourcees lo que hay que vigilar (así que basta con un--cova secas, sin valor),branch = truees--cov-branch.[tool.coverage.report]controla el informe:fail_underes--cov-fail-under, yexclude_alsolista patrones a excluir además de# pragma: no cover—aquí todo bloque__main__, sin necesidad de ningún comentario en el código—.
Ahora un pytest a secas mide, informa y hace cumplir el umbral. Lo verás funcionar en el ejemplo completo de abajo.
100% de cobertura no significa que sea correcto
La cobertura responde «¿esta línea se ejecutó?». No responde «¿alguien comprobó que el resultado era correcto?». Un ejemplo pequeño:
def is_leap_year(year: int) -> bool:
return year % 4 == 0from shop.calendar_rules import is_leap_year
def test_2024_is_a_leap_year():
assert is_leap_year(2024)
def test_2023_is_not():
assert not is_leap_year(2023)$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/calendar_rules.py 2 0 0 0 100%
--------------------------------------------------------------------
TOTAL 2 0 0 0 100%
2 passed in 0.12sPuntuación perfecta. Y la función está mal: un año divisible por 100 no es bisiesto salvo que también sea divisible por 400. Un test más, elegido pensando en la regla y no en las líneas:
def test_1900_is_not():
assert not is_leap_year(1900)$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
..F [100%]
=================================== FAILURES ===================================
_______________________________ test_1900_is_not _______________________________
def test_1900_is_not():
> assert not is_leap_year(1900)
E assert not True
E + where True = is_leap_year(1900)
tests/test_calendar_rules.py:13: AssertionError
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/calendar_rules.py 2 0 0 0 100%
--------------------------------------------------------------------
TOTAL 2 0 0 0 100%
=========================== short test summary info ============================
FAILED tests/test_calendar_rules.py::test_1900_is_not - assert not True
1 failed, 2 passed in 0.12sSigue siendo 100% —y ahora hay un fallo. La cobertura no podría haber señalado este error, porque no había ninguna línea que faltara; lo que faltaba era un caso. Trata la cobertura como un mapa de dónde con seguridad no has mirado, nunca como prueba de que lo que miraste está bien.
Tests en paralelo con pytest-xdist
A medida que una suite crece se vuelve lenta, y una suite lenta se ejecuta menos veces. pytest-xdist reparte los tests entre varios procesos. Ocho tests que tardan medio segundo cada uno:
import time
import pytest
@pytest.mark.parametrize("n", range(8))
def test_slow_check(n):
time.sleep(0.5)
assert n >= 0$ pytest -q test_slow.py
........ [100%]
8 passed in 4.10s
$ pytest -n auto test_slow.py
============================= test session starts ==============================
created: 4/4 workers
4 workers [8 items]
........ [100%]
============================== 8 passed in 1.52s ===============================-n auto arranca un worker por núcleo de CPU (cuatro en esta máquina) y les asigna tests para ejecutar. Funciona junto con --cov; los datos de cobertura de todos los workers se combinan en un solo informe.
Hay un precio. Cada worker es un proceso separado, y el orden en que se ejecutan los tests ya no es el orden del archivo. Cualquier test que diera por hecho que otro se hubiera ejecutado primero se romperá:
CART = []
def test_add_item():
CART.append("pen")
assert CART == ["pen"]
def test_cart_has_one_item():
assert len(CART) == 1$ pytest -q test_cart.py
.. [100%]
2 passed in 0.07s
$ pytest -n 2 test_cart.py
============================= test session starts ==============================
created: 2/2 workers
2 workers [2 items]
.F [100%]
=================================== FAILURES ===================================
____________________________ test_cart_has_one_item ____________________________
[gw1] linux -- Python 3.12.3 /path/to/venv/bin/python
def test_cart_has_one_item():
> assert len(CART) == 1
E assert 0 == 1
E + where 0 = len([])
test_cart.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_cart_has_one_item - assert 0 == 1
========================= 1 failed, 1 passed in 0.37s ==========================El segundo test solo pasó porque el primero ya había rellenado la lista compartida. En otro worker, la lista estaba vacía. Ejecutar pytest test_cart.py::test_cart_has_one_item por separado falla de la misma manera —xdist no creó el error, lo reveló—.
La solución es la de los capítulos de fixtures: dar a cada test su propio estado.
import pytest
@pytest.fixture
def cart():
return ["pen"]
def test_add_item(cart):
cart.append("bag")
assert cart == ["pen", "bag"]
def test_cart_has_one_item(cart):
assert len(cart) == 1$ pytest -n 2 test_cart_fixed.py
============================= test session starts ==============================
created: 2/2 workers
2 workers [2 items]
.. [100%]
============================== 2 passed in 0.39s ===============================Un test debe pasar por separado, en cualquier orden, en cualquier worker. Hay un plugin, pytest-randomly, construido enteramente sobre esa idea: baraja el orden de los tests en cada ejecución, así que una dependencia oculta aparece pronto, en tu máquina, en lugar de meses después en CI. No lo necesitas para seguir este curso; basta con saber que «la suite solo pasa en el orden del archivo» es un error.
Ejecutarlo en cada push: GitHub Actions
El último paso es dejar de depender de que la gente se acuerde de ejecutar la suite. Declara las herramientas de test como un grupo de desarrollo en pyproject.toml, para que uv sync las instale:
[dependency-groups]
dev = [
"pytest>=9",
"pytest-cov>=7",
"pytest-xdist>=3.8",
]Luego añade .github/workflows/tests.yml:
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --locked
- name: Run tests
run: uv run pytest -n auto --cov-report=xml
- name: Keep the coverage report
if: matrix.python-version == '3.12'
uses: actions/upload-artifact@v7
with:
name: coverage-xml
path: coverage.xmlQué hace cada parte:
on:— se ejecuta en cada push amainy en cada pull request.matrix:— el job se ejecuta tres veces, una por versión de Python, en paralelo.fail-fast: falsedeja que los tres terminen, para que veas si un fallo es específico de una versión.setup-uvinstala uv y fija la versión de Python para esa copia del job.uv sync --lockedinstala exactamente lo que diceuv.locky falla si el archivo de bloqueo está desactualizado —así que CI prueba lo que has confirmado—.uv run pytest -n auto --cov-report=xmlse ejecuta en paralelo; los ajustes de cobertura,fail_underincluido, vienen depyproject.toml, así que una caída de la cobertura hace fallar el build. El informe XML queda junto al de terminal, listo para un servicio de cobertura o para descargarlo.
En local, después de uv sync, el mismo comando da el mismo resultado (las últimas líneas de la salida):
$ uv run pytest -n auto --cov-report=xml
TOTAL 15 0 10 1 96%
Coverage XML written to file coverage.xml
Required test coverage of 90.0% reached. Total coverage: 96.00%
============================== 7 passed in 0.43s ===============================Un ejemplo completo
Un proyecto con estructura src, los dos módulos de este capítulo y cada ajuste en un solo sitio.
shop-project/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── coupons.py
│ └── shipping.py
└── tests/
├── test_coupons.py
└── test_shipping.pypyproject.toml es el de la sección de configuración de arriba. coupons.py conserva su bloque __main__, sin pragma — exclude_also se encarga de él. test_coupons.py sigue teniendo solo el test SAVE10. Y test_shipping.py es ahora el plan de tests del principio del capítulo, fila por fila:
import pytest
from shop.shipping import shipping_cost
@pytest.mark.parametrize(
("weight", "country", "expected"),
[
(1, "BD", 60.0),
(1, "IN", 90.0),
(1, "US", 500.0),
(5, "BD", 60.0),
(7, "BD", 100.0),
],
)
def test_shipping_cost(weight, country, expected):
assert shipping_cost(weight, country) == expected
def test_zero_weight_is_rejected():
with pytest.raises(ValueError, match="positive"):
shipping_cost(0, "BD")$ pytest
============================= test session starts ==============================
configfile: pyproject.toml
testpaths: tests
collected 7 items
tests/test_coupons.py . [ 14%]
tests/test_shipping.py ...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/coupons.py 4 0 2 1 83% 2->4
src/shop/shipping.py 11 0 8 0 100%
------------------------------------------------------------------
TOTAL 15 0 10 1 96%
Required test coverage of 90.0% reached. Total coverage: 96.00%
============================== 7 passed in 0.12s ===============================Tres cosas que vale la pena notar.
Primero, pytest a secas hizo todo —sin flags—. La medición, la comprobación de ramas, la lista de faltantes y el umbral vinieron todos de pyproject.toml.
Segundo, shipping.py pasó de 64% a 100% porque el plan quedó por escrito, no porque persiguieramos líneas rojas. La cobertura entonces confirma que el plan llegó a cada línea y a cada rama. La fila (5, "BD", 60.0) y la elección de 0 en vez de -1 para el peso inválido no añaden nada al porcentaje —están ahí porque el contrato tiene bordes, y los bordes es donde viven erratas como < por <=—.
Tercero, el build pasa con 96% mientras 2->4 sigue listado. El umbral es un suelo, no una meta. El informe sigue diciéndote exactamente qué test escribir a continuación: final_price sin cupón.
Cuando algo falla
error: unrecognized arguments: --cov=shop pytest-cov no está instalado en el entorno desde el que corre pytest. Instálalo (uv add --dev pytest-cov) y ejecuta usando ese entorno, por ejemplo con uv run pytest. El mismo mensaje con -n significa que falta pytest-xdist.
CoverageWarning: Module shopp was never imported. (module-not-imported) seguido de No data was collected. El nombre después de --cov= no coincide con ningún paquete que tus tests hayan importado —aquí una errata—. Revisa la ortografía y, con estructura src, da el nombre de import (shop), no la carpeta (src).
WARNING: Failed to generate report: No data to report. La misma causa de arriba: no se midió nada, así que no hay nada que reportar. Los tests pasaron igual, y por eso mismo este es fácil de pasar por alto.
FAIL Required test coverage of 90% not reached. Total coverage: 83.33% No es un test roto —es el umbral—. Mira la columna Missing y escribe el test al que apunta. Bajar fail_under para poner el build en verde echa por tierra el sentido de tenerlo.
Un test pasa por separado pero falla con -n auto (o al revés) Los tests comparten estado: una lista o diccionario a nivel de módulo, un archivo en una ruta fija, una variable de entorno, una fila de base de datos. Busca lo que el test que falla da por supuesto que ya está ahí, y dáselo al test mediante una fixture, tmp_path o monkeypatch en su lugar.
La cobertura es 100% pero los usuarios siguen encontrándose errores Esperado. La cobertura muestra lo que se ejecutó, no lo que se comprobó. Busca casos —fronteras, entradas vacías, el año 1900—, no líneas.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Solo hay un test, y la cobertura de ramas está activada. ¿Qué muestra la columna Missing en la fila de coupons.py?
# shop/coupons.py
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)
# tests/test_coupons.py
def test_coupon_takes_ten_percent_off():
assert final_price(200.0, "SAVE10") == 180.0
# command: pytest -q --cov=shop --cov-branch --cov-report=term-missing- ANada — 100% cubierto
- B4
- C2->4
- D3
Con pytest los dos tests pasan, pero con pytest -n 2 test_cart_has_one_item falla. ¿Cuál es el problema real?
CART = []
def test_add_item():
CART.append("pen")
assert CART == ["pen"]
def test_cart_has_one_item():
assert len(CART) == 1- Apytest-xdist tiene un error; los tests están bien
- BEl segundo test depende de que el primero se haya ejecutado —los dos tests comparten estado—
- C`-n 2` ejecuta cada test dos veces
- D`CART` está a nivel de módulo, así que nunca se crea en los workers
Una función tiene 100% de cobertura de líneas y de ramas, y todos los tests pasan. ¿Qué puedes concluir?
- ASolo que cada línea y cada lado de cada decisión se ejecutó al menos una vez —no que los resultados sean correctos—
- BCon `fail_under = 100`, ningún error puede volver a entrar en la función
- CLa función no puede tener un error de frontera
- DLa función es correcta y no necesita más tests
Para responder necesitas una cuenta
Inicia sesión para comprobar tus respuestas
Las preguntas están arriba, y resolverlas mentalmente es lo que de verdad importa. Inicia sesión para ver las respuestas, las explicaciones y las pistas de tres niveles.
Tu turno
Empieza un proyecto pequeño con este módulo, src/shop/loyalty.py:
def loyalty_points(total: float, is_member: bool) -> int:
if total < 0:
raise ValueError(f"total cannot be negative: {total}")
points = int(total // 100)
if is_member:
points *= 2
return pointsEl contrato: un punto por cada 100 completos gastados, doble para miembros; un total negativo es un error; cero está permitido. Viene con un solo test, tests/test_loyalty.py:
from shop.loyalty import loyalty_points
def test_member_gets_double_points():
assert loyalty_points(250, True) == 4Y un pyproject.toml que mide la cobertura de ramas en cada ejecución:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = true- Ejecuta
pytest -qy lee la columnaMissing. Di con palabras qué significa cada entrada. - Antes de escribir ningún test, escribe una tabla de plan de tests para el contrato —incluyendo la frontera en exactamente 100 y el borde justo por debajo—.
- Convierte el plan en un test parametrizado más un test con
pytest.raises. - Añade
fail_under = 100bajo[tool.coverage.report]y confirma que la ejecución pasa. - Borra las filas de no miembro y confirma que la ejecución falla aunque cada test restante pase.
- Ejecuta la suite con
pytest -n autoy confirma que el resultado es idéntico.
Solución
Paso 1 — leer el informe.
$ pytest -q
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 1 4 2 73% 3, 5->7
------------------------------------------------------------------
TOTAL 7 1 4 2 73%
1 passed in 0.09s3 es la línea del raise: ningún test pasó un total negativo. 5->7 es un salto del if is_member: directamente a return points —el camino de no miembro, nunca tomado—. Sin cobertura de ramas, 5->7 no aparecería en absoluto, porque la línea 6 sí se ejecutó (para el miembro).
Paso 2 — el plan.
| Caso | Entrada | Esperado | |---|---|---| | miembro, doble | 250, True | 4 | | no miembro | 250, False | 2 | | justo por debajo del primer 100 | 99.99, False | 0 | | frontera: exactamente 100 | 100, False | 1 | | cero está permitido | 0, True | 0 | | inválido: negativo | -1, True | ValueError, "cannot be negative" |
Paso 3 — los tests. tests/test_loyalty.py:
import pytest
from shop.loyalty import loyalty_points
@pytest.mark.parametrize(
("total", "is_member", "expected"),
[
(250, True, 4), # member: points doubled
(250, False, 2), # non-member: the `if` is skipped
(99.99, False, 0), # just under the first 100
(100, False, 1), # exactly on the boundary
(0, True, 0), # zero is allowed, not an error
],
)
def test_loyalty_points(total, is_member, expected):
assert loyalty_points(total, is_member) == expected
def test_negative_total_is_rejected():
with pytest.raises(ValueError, match="cannot be negative"):
loyalty_points(-1, True)Paso 4 — la puerta. Añade a pyproject.toml:
[tool.coverage.report]
fail_under = 100$ pytest -q
...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 0 100%
------------------------------------------------------------------
TOTAL 7 0 4 0 100%
Required test coverage of 100.0% reached. Total coverage: 100.00%
6 passed in 0.09sPaso 5 — demostrar que la puerta funciona. Con las tres filas False borradas:
$ pytest -q
...
ERROR: Coverage failure: total of 91 is less than fail-under=100
[100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 1 91% 5->7
------------------------------------------------------------------
TOTAL 7 0 4 1 91%
FAIL Required test coverage of 100.0% not reached. Total coverage: 90.91%
3 passed in 0.12s3 passed, estado de salida 1. Cada línea sigue ejecutándose —solo la rama desapareció—, y la puerta lo detecta. Vuelve a poner las filas.
Paso 6 — en paralelo.
$ pytest -n auto
============================= test session starts ==============================
configfile: pyproject.toml
testpaths: tests
created: 4/4 workers
4 workers [6 items]
...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 0 100%
------------------------------------------------------------------
TOTAL 7 0 4 0 100%
Required test coverage of 100.0% reached. Total coverage: 100.00%
============================== 6 passed in 0.63s ===============================Los mismos seis tests, el mismo 100%: la cobertura de cuatro workers se fusionó en un solo informe.
El porqué de cada decisión.
- El plan vino antes que los tests. De las seis filas, solo dos las exigió el informe (
3y5->7).99.99y100están ahí porque el contrato tiene un borde en 100, y0, Trueporque el contrato dice que cero está permitido —un test que comprueba que no lanza excepción—. La cobertura se habría satisfecho sin ninguno de ellos. - Por qué `-1` está bien aquí. La guarda es
total < 0, así que el borde en sí es0, y la fila0, Trueya lo fija como válido. Si alguien cambiara la guarda atotal <= 0, esa fila fallaría. Entonces cualquier número negativo sirve para el lado inválido. - Por qué `fail_under = 100` aquí y no en un proyecto real. Para un módulo tan pequeño es razonable cubrirlo por completo. En una base de código grande, empieza justo por debajo de la cifra de hoy y súbelo.
- Por qué la ejecución con `-n auto` demuestra algo. Cada caso recibe sus propios argumentos y no se comparte nada entre tests, así que ni el orden ni el worker importan. Si el resultado hubiera cambiado bajo
-n auto, ese habría sido el error que había que arreglar primero.
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Inicia sesión para hacer el quiz