Configuración y avisos — las reglas en un solo archivo
Deja de escribir los mismos flags en cada ejecución: pon las reglas del proyecto en pyproject.toml o pytest.ini — testpaths, addopts, pythonpath, markers, xfail_strict, log_cli — y prueba y controla los avisos con pytest.warns, deprecated_call y filterwarnings.
¿Cómo quieres aprender?
El problema que vamos a resolver
El proyecto shop ya tiene la forma que comparten la mayoría de los proyectos reales: código en src/, tests en tests/.
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ └── pricing.py
└── tests/
└── test_pricing.pyHas aprendido a ejecutarlo correctamente, y «correctamente» se ha convertido en una línea larga:
PYTHONPATH=src pytest -ra --strict-markers testsUn compañero clona el repositorio y escribe lo que cualquiera escribiría:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 0 items / 1 error
==================================== ERRORS ====================================
____________________ ERROR collecting tests/test_pricing.py ____________________
ImportError while importing test module '/home/you/shop/tests/test_pricing.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_pricing.py:1: in <module>
from shop.pricing import line_total
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.01s ===============================No hay nada mal en el código. Lo que está mal es que la forma de ejecutar los tests vive en tu cabeza y en el historial de tu shell en lugar de en el proyecto. CI ejecutará una tercera variación, y tres personas verán tres resultados a partir de un mismo commit.
Esos flags no son una preferencia tuya; son las reglas del proyecto. Este capítulo los mueve a un archivo que pytest lee cada vez —y luego usa ese mismo archivo para resolver un segundo problema, más silencioso: los avisos que se deslizan por pantalla y nunca se leen.
Al terminar este capítulo podrás
- Poner la configuración de pytest en
pyproject.toml(opytest.ini) y confirmar por el encabezado qué archivo se usó - Elegir
testpaths,addopts,pythonpath,minversion,markersyxfail_strictcon criterio, y decir por qué - Activar los logs en vivo con
log_clicuando necesites observar una ejecución - Comprobar que el código emite un aviso con
pytest.warnsypytest.deprecated_call, usandomatchy la lista grabada - Controlar los avisos con
filterwarningsen la configuración, con una marca y con-W—y convertirlos en errores en CI
Requisitos previos: markers, skip and xfail.
Antes de escribir el test
Este capítulo escribe menos tests de lo habitual y más reglas sobre cómo se ejecutan los tests. El razonamiento que viene primero es el mismo: decide qué se promete, y luego planifica los casos que comprueban la promesa.
Qué se promete. Hay dos tipos de promesa en juego.
- Cómo se ejecuta la suite. Cualquiera que escriba
pytesta secas obtiene los mismos tests, la misma ruta de importación y la misma rigurosidad que CI. Un marcador mal escrito detiene la ejecución. Unxfailque empieza a pasar se reporta, no se oculta. - Qué dice el código además de su valor de retorno.
apply_discount(total, percent)devuelve un número, y conpercent > 50también emite unUserWarningque dice que el descuento "looks unusually large".total_with_vat()sigue funcionando pero emite unDeprecationWarningque apunta aline_total(). Un aviso es salida; cuando forma parte del contrato, se prueba igual que la salida.
Qué debe estar listo. Un entorno virtual con pytest 9 (pytest --version), el código importable desde los tests (eso es pythonpath más abajo), y un archivo de configuración en la raíz del proyecto. Ningún plugin extra.
El plan para el comportamiento de los avisos:
| Caso | Entrada | Esperado | | --- | --- | --- | | camino feliz | apply_discount(200.0, 10) | 180.0, sin aviso | | frontera, todavía en silencio | apply_discount(200.0, 50) | 100.0, sin aviso | | justo más allá de la frontera | apply_discount(200.0, 60) | 80.0 y un UserWarning que menciona "unusually large" | | nombre obsoleto | total_with_vat(100.0, 1) | 115.0 y un DeprecationWarning que menciona line_total | | la propia obsolescencia de una biblioteca | cualquier cosa que la active | no falla nuestra suite |
Para la configuración en sí, el «test» es una ejecución: un pytest a secas desde la raíz y desde dentro de tests/ debe recopilar solo tests/ y pasar en ambos casos, y un marcador mal escrito debe detener la ejecución.
Qué no probar. No que el módulo warnings de Python funcione, ni que pytest lea su configuración —eso es código probado por otros—. No el texto completo de un aviso: haz coincidir la frase sobre la que actúa un lector ("unusually large"), de modo que reformular el resto no rompe nada. Y no las obsolescencias de una biblioteca de terceros: esas se filtran, lo cual es una decisión de configuración, no un test.
La fila de la frontera es la que la gente olvida, porque un aviso que falta no hace ruido. Al final del capítulo, una línea de configuración la convierte en una comprobación que obtienes gratis.
Un archivo, leído antes que nada
pytest lee su configuración antes de recopilar un solo test. El hogar habitual es el pyproject.toml que el proyecto ya tiene, en una tabla llamada [tool.pytest.ini_options]:
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
pythonpath = ["src"]pythonpath lista directorios, relativos a la raíz del proyecto, que pytest añade a sys.path antes de importar los tests. El código bajo prueba es el de siempre:
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)$ PYTHONPATH=src python -c "from shop.pricing import line_total; print(line_total(15.0, 3))"
51.75
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 items
tests/test_pricing.py .. [100%]
============================== 2 passed in 0.01s ===============================Lee el encabezado. configfile: pyproject.toml es pytest diciéndote qué archivo usó. Si alguna vez los ajustes parecen ignorados, comprueba esta línea primero.
¿Por qué no arreglar el import dentro del archivo de test? Es la forma equivocada obvia, y funciona —a veces—:
import sys
sys.path.insert(0, "src") # relative to wherever pytest was started
from shop.pricing import line_total
def test_three_items():
assert line_total(15.0, 3) == 51.75$ pytest -q
. [100%]
1 passed in 0.01s
$ cd tests && pytest -q
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s"src" ahí es relativo al directorio actual, así que el test pasa o falla según dónde te encuentres. pythonpath en la configuración es relativo a la raíz del proyecto, que no se mueve. La preparación de los imports pertenece al proyecto, así que va en el archivo del proyecto una sola vez, no en la parte superior de cada módulo de test.
Qué archivo manda, y dónde está la raíz
pytest acepta varios archivos. En cada directorio comprueba, en este orden: pytest.toml, .pytest.toml, pytest.ini, .pytest.ini, pyproject.toml, tox.ini, setup.cfg. Empieza en las rutas de la línea de comandos (o el directorio actual) y sube hacia arriba; el primer archivo que contiene de verdad una sección de pytest es el que gana. Un pyproject.toml sin tabla de pytest no cuenta.
Ese directorio se convierte en la rootdir —la línea rootdir: del encabezado—, y las rutas relativas de la configuración son relativas a él. Así que estar dentro de tests/ sigue encontrando la raíz:
$ cd tests && pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 itemsLos mismos ajustes en pytest.ini usan sintaxis INI, una sección [pytest], y sin comillas ni corchetes:
[pytest]
pythonpath = src
testpaths = tests
addopts = -ra --strict-markers
markers =
slow: takes more than a second; deselect with -m 'not slow'Elige un archivo. Si existen ambos, pytest.ini gana por completo —no se fusiona nada—, y el encabezado lo dice. Aquí se añadió un pytest.ini de dos líneas (minversion = 9.0) junto al pyproject.toml:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)
collected 0 items / 1 error…y el ModuleNotFoundError vuelve, porque pythonpath vivía en el archivo que ahora se ignora.
testpaths — dónde buscar cuando no dices nada
Alguien dejó un archivo de pruebas, notes/test_scratch.py, probando redondeos. Un pytest a secas recopila todo lo que hay bajo la raíz, así que también lo ejecuta:
$ pytest -q --tb=no
F.. [100%]
=========================== short test summary info ============================
FAILED notes/test_scratch.py::test_try_rounding - assert 2.67 == 2.68
1 failed, 2 passed in 0.01stestpaths dice dónde vive la suite real:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]$ pytest -q
.. [100%]
2 passed in 0.01sEn el encabezado completo ahora también verías testpaths: tests. Es un valor por defecto, no una cerca: se aplica solo cuando ejecutas desde la raíz sin nombrar rutas, así que pytest notes sigue ejecutando el archivo de pruebas. En un repositorio grande también acelera la recopilación, porque pytest deja de recorrer docs/, build/ o la carpeta de un entorno virtual.
addopts y markers — reglas que todos aplican
addopts es texto que pytest pone delante de lo que tú escribes. Las dos reglas del proyecto del principio van aquí:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"-ra imprime una línea de resumen corta para cada test que no pasó de forma clara —omitido, xfailed, xpassed, fallado—, así que un skip nunca pasa desapercibido. --strict-markers convierte un marcador no registrado en un error. Un archivo nuevo, tests/test_discounts.py, tiene un test marcado @pytest.mark.slow (y uno omitido fuera de Windows), y el marcador todavía no está registrado:
$ pytest -q
==================================== ERRORS ====================================
___________________ ERROR collecting tests/test_discounts.py ___________________
'slow' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR tests/test_discounts.py - Failed: 'slow' not found in `markers` configu...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sEse es el punto del flag. Sin él, una errata como @pytest.mark.slwo solo merece un PytestUnknownMarkWarning en el resumen, la ejecución sigue en verde, y -m "not slow" sigue ejecutando el test que querías dejar fuera. Registra el marcador, una línea cada uno, con una descripción que pytest --markers mostrará:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01s
$ pytest --markers
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'La línea SKIPPED es -ra en acción: ahora un skip se explica por sí mismo en cada ejecución.
Lo que no pertenece en addopts. La forma equivocada es addopts = "-v -x --pdb". Esos son tus hábitos para la depuración de hoy; en el archivo compartido se los impone a todo el mundo, CI incluido —donde --pdb espera para siempre a un teclado al que nadie está sentado—. Pon en addopts solo las reglas del proyecto. Lo que tú escribes se añade después, así que pytest -x sigue funcionando solo para ti, y -o anula cualquier ajuste durante una sola ejecución:
$ pytest -q -o addopts=""
.s.. [100%]
3 passed, 1 skipped in 0.01sEsta vez no hay línea de skip, porque -ra se quitó para esa ejecución.
minversion y xfail_strict
minversion detiene la ejecución al instante cuando el pytest de alguien es demasiado viejo para el archivo. Pedir una versión que no existe muestra el mensaje:
$ pytest
ERROR: /home/you/mv/pytest.ini: 'minversion' requires pytest-10.0, actual pytest-9.1.1'Un error claro al arrancar es mejor que un pytest antiguo entendiendo a medias la configuración.
xfail_strict trata el cabo suelto del capítulo anterior: un xfail que empieza a pasar. Aquí un error estaba marcado como esperado, y desde entonces alguien lo corrigió:
import pytest
def refund(amount):
return round(amount * 0.9, 2)
@pytest.mark.xfail(reason="bug #41: refunds lose a cent")
def test_refund_keeps_cents():
assert refund(10.0) == 9.0$ pytest -q
X [100%]
1 xpassed in 0.01sUna ejecución en verde, una X que nadie lee, y un marcador que ahora miente. Con xfail_strict = true en la configuración:
$ pytest -q
F [100%]
=================================== FAILURES ===================================
___________________________ test_refund_keeps_cents ____________________________
[XPASS(strict)] bug #41: refunds lose a cent
=========================== short test summary info ============================
FAILED test_refunds.py::test_refund_keeps_cents - [XPASS(strict)] bug #41: re...
1 failed in 0.01sLa buena noticia ahora llega como un fallo que te dice que elimines el marcador —por eso vale la pena configurarlo para todo el proyecto en lugar de test a test—. pytest 9.1 también acepta el nombre más nuevo strict_xfail, y un strict = true único que activa de golpe todas las opciones de rigurosidad (marcadores y xfail incluidos); xfail_strict sigue funcionando.
Logs en vivo: log_cli
Por defecto pytest captura los registros de log y los muestra solo para los tests que fallan. Para ver cómo cuenta su historia una ejecución que pasa, activa los logs en vivo:
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"Los registros INFO y superiores de cada test se imprimen entonces bajo un encabezado live log call a medida que se ejecuta; el ejemplo completo de abajo lo muestra. Los logs en vivo son ruidosos, así que muchos proyectos dejan solo log_cli_level en el archivo y encienden la salida cuando hace falta con pytest -o log_cli=true.
La tabla nativa de pytest 9: [tool.pytest]
[tool.pytest.ini_options] tiene un nombre raro por una razón: son ajustes de INI sentados en un archivo TOML, así que cada valor se convierte en texto. pytest 9 añade una tabla nativa, [tool.pytest], donde los valores conservan sus tipos TOML —listas de verdad, booleanos de verdad—:
[tool.pytest]
minversion = "9.0"
pythonpath = ["src"]
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers"]
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
strict_xfail = true$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01saddopts ahora es una lista. Dos reglas: usa una u otra tabla, nunca ambas en un mismo archivo (pytest se niega a arrancar), y recuerda que las versiones de pytest anteriores a la 9 no leen [tool.pytest] en absoluto. Este capítulo conserva [tool.pytest.ini_options] porque funciona en cualquier pytest que probablemente vayas a encontrar; en un proyecto que solo ejecuta pytest 9, la tabla nativa es la forma recomendada. pytest 9 también lee un pytest.toml independiente con una tabla [pytest] en el mismo estilo nativo.
La caché, en breve
Después de una ejecución hay una carpeta .pytest_cache/ en la raíz. Recuerda qué tests fallaron la última vez, que es lo que alimenta --lf (last failed) y --ff (failed first). Hay dos flags que vale la pena conocer. pytest --cache-clear la vacía antes de la ejecución —útil cuando --lf sigue eligiendo tests que ya no existen—. pytest -p no:cacheprovider desactiva por completo el plugin de caché, así que no se escribe nada, lo cual le va bien a una copia de solo lectura. El coste es que las opciones del plugin desaparecen con él:
$ pytest -q -p no:cacheprovider --lf
ERROR: usage: pytest [options] [file_or_dir] [file_or_dir] [...]
pytest: error: unrecognized arguments: --lf
inifile: /home/you/mv/pytest.ini
rootdir: /home/you/mvAvisos: la sección al final de la ejecución
Un aviso es un mensaje de que algo todavía no está mal. Python lo imprime y sigue:
import warnings
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
return round(total * (100 - percent) / 100, 2)$ python -c "from pricing import apply_discount; print(apply_discount(200.0, 60))"
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
80.0stacklevel=2 hace que la obsolescencia apunte a la línea del llamante, la línea que alguien tiene que cambiar. pytest captura los avisos lanzados durante los tests y los reúne en una sola sección al final:
from pricing import total_with_vat
def test_old_name_still_works():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
. [100%]
=============================== warnings summary ===============================
test_old_api.py::test_old_name_still_works
/home/you/wn/test_old_api.py:5: DeprecationWarning: total_with_vat() is deprecated; use line_total()
assert total_with_vat(100.0, 1) == 115.0
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
1 passed, 1 warning in 0.01sEl test pasó, y la última línea dice 1 passed, 1 warning. En una suite grande esta sección crece hasta docenas de entradas, todo el mundo aprende a desplazarse por encima de ella, y un día la función obsoleta se elimina y la suite que «pasaba» se rompe. Con un aviso puedes hacer dos cosas: esperarlo, cuando forma parte del comportamiento, o rechazarlo, cuando no lo es.
pytest.warns — esperar un aviso
Cuando un aviso forma parte del contrato —la fila «justo más allá de la frontera» del plan—, pruébalo como pruebas una excepción con pytest.raises:
import pytest
from pricing import apply_discount
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large"):
result = apply_discount(200.0, 60)
assert result == 80.0
def test_small_discount_is_quiet():
assert apply_discount(200.0, 10) == 180.0$ pytest -q test_discount.py
.. [100%]
2 passed in 0.01sEl bloque pasa cuando se emite al menos un UserWarning (o subclase) dentro de él y su mensaje coincide con match, una expresión regular que se busca en el texto, como con pytest.raises. Las dos mitades fallan con ruido. Aquí el código está en silencio con 50, y el segundo test busca las palabras equivocadas:
import pytest
from pricing import apply_discount
def test_fifty_percent_warns():
with pytest.warns(UserWarning):
apply_discount(200.0, 50)
def test_wrong_message():
with pytest.warns(UserWarning, match="too large"):
apply_discount(200.0, 60)$ pytest -q test_discount_bad.py
FF [100%]
=================================== FAILURES ===================================
___________________________ test_fifty_percent_warns ___________________________
def test_fifty_percent_warns():
> with pytest.warns(UserWarning):
E Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted.
E Emitted warnings: [].
test_discount_bad.py:7: Failed
______________________________ test_wrong_message ______________________________
def test_wrong_message():
> with pytest.warns(UserWarning, match="too large"):
E Failed: Regex pattern did not match any of the 1 warnings emitted.
E Regex: 'too large'
E Emitted warnings: [UserWarning('discount of 60% looks unusually large')].
test_discount_bad.py:12: Failed
=============================== warnings summary ===============================
test_discount_bad.py::test_wrong_message
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED test_discount_bad.py::test_fifty_percent_warns - Failed: DID NOT WARN....
FAILED test_discount_bad.py::test_wrong_message - Failed: Regex pattern did n...
2 failed, 1 warning in 0.01sAmbos reportes listan los avisos que sí se emitieron, que suele ser todo lo que necesitas para arreglar el test. Un aviso que no coincidió tampoco se traga; pasa al resumen de avisos.
Por qué dar siempre match. pytest.warns(UserWarning) a secas es la forma débil: cualquier UserWarning de cualquier parte del bloque la satisface —incluido uno de una biblioteca que resulta que llamas, por un motivo sin relación—. El test podría seguir pasando después de que el aviso que te importa desaparezca. Una frase que coincide sujeta el test a este aviso.
Para comprobar cuántos avisos se dispararon, o qué decían, guarda el registro con as:
import pytest
from pricing import apply_discount
def test_each_large_discount_warns_once():
with pytest.warns(UserWarning) as record:
apply_discount(100.0, 60)
apply_discount(100.0, 10)
apply_discount(100.0, 75)
assert len(record) == 2
print(record[0].message)
print(record[1].category.__name__, record[1].lineno)$ pytest -q -s test_record.py
discount of 60% looks unusually large
UserWarning 19
.
1 passed in 0.01srecord lista cada aviso atrapado en el bloque —dos, porque la llamada del 10% está en silencio—. Cada entrada tiene .message, .category, .filename y .lineno.
pytest.deprecated_call — una promesa a tus usuarios
Marcar una función como obsoleta es una promesa: «esto sigue funcionando, y te lo hemos dicho». pytest.deprecated_call() comprueba las dos mitades:
import pytest
from pricing import total_with_vat
def test_old_name_still_works():
with pytest.deprecated_call():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q test_old_api.py
. [100%]
1 passed in 0.01sEs pytest.warns con las categorías rellenas —DeprecationWarning, PendingDeprecationWarning y FutureWarning—, y también acepta match. La entrada en el resumen de avisos desaparece: el aviso era esperado, así que se consumió.
filterwarnings — rechazar los avisos
Los avisos esperados ya son tests. El resto es ruido o alarmas tempranas, y el hábito de CI que vale la pena adoptar es: cada aviso inesperado es un error. Una línea:
[tool.pytest.ini_options]
filterwarnings = [
"error",
]Aquí hay una suite con un test limpio, otro que todavía llama al nombre obsoleto, y otro que llama a currency.rate() de una biblioteca que no puedes editar, que emite su propio DeprecationWarning:
from currency import rate
from pricing import line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0
def test_rate():
assert rate("USD") == 1.0$ pytest -q --tb=short
.FF [100%]
=================================== FAILURES ===================================
________________________________ test_old_name _________________________________
test_shop.py:10: in test_old_name
assert total_with_vat(100.0, 1) == 115.0
^^^^^^^^^^^^^^^^^^^^^^^^
pricing.py:9: in total_with_vat
warnings.warn(
E DeprecationWarning: total_with_vat() is deprecated; use line_total()
__________________________________ test_rate ___________________________________
test_shop.py:14: in test_rate
assert rate("USD") == 1.0
^^^^^^^^^^^
currency.py:6: in rate
warnings.warn("rate() will need an API key from v3", DeprecationWarning)
E DeprecationWarning: rate() will need an API key from v3
=========================== short test summary info ============================
FAILED test_shop.py::test_old_name - DeprecationWarning: total_with_vat() is ...
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
2 failed, 1 passed in 0.01sCada aviso se convierte ahora en una excepción, con un traceback hasta la línea que lo causó. Cada fallo recibe una decisión en lugar de un silencio general.
La forma equivocada es "ignore::DeprecationWarning": una línea, todo en verde, y tu propia obsolescencia oculta junto con la de la biblioteca. La forma correcta es estrechar cada excepción. Un filtro tiene la forma action:message:category:module —la misma que la propia opción -W de Python—, donde message coincide con el inicio del texto del aviso y module es el módulo al que se atribuye el aviso:
[tool.pytest.ini_options]
filterwarnings = [
"error",
"ignore::DeprecationWarning:currency",
]El orden importa: las líneas posteriores tienen prioridad, así que error va primero y las excepciones después. El aviso de la biblioteca ahora se ignora solo cuando viene de currency. Para un solo test, el mismo filtro puede adjuntarse como una marca —aquí en test_old_name, con el resto del archivo sin cambios—:
import pytest
from pricing import total_with_vat
@pytest.mark.filterwarnings("ignore:total_with_vat:DeprecationWarning")
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
... [100%]
3 passed in 0.01sEsa marca solo permite el aviso. Si lo que quieres es «esto debe avisar», pytest.deprecated_call() es la opción más fuerte, porque también falla cuando el aviso desaparece. Usa la marca para los avisos que toleras y warns para los avisos que prometes.
Los mismos filtros pueden darse para una sola ejecución con -W, cómodo para probar una política antes de escribirla. En el test_old_api.py anterior sin ninguna configuración:
$ pytest -q -W error::DeprecationWarning --tb=no -ra
F [100%]
=========================== short test summary info ============================
FAILED test_old_api.py::test_old_name_still_works - DeprecationWarning: total...
1 failed in 0.01sCuando las fuentes discrepan, gana la más específica: una marca en el test le gana a -W, y -W le gana al archivo de configuración. En la suite de tres tests, -W anula el ignore de la configuración para currency, mientras el test marcado conserva su exención:
$ pytest -q -W error::DeprecationWarning --tb=no -ra
..F [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
1 failed, 2 passed in 0.01sUna cosa más que te compra error. Vuelve a la fila de la frontera del plan, «sin aviso con 50». Con error en la configuración, un test ordinario de apply_discount(200.0, 50) ya lo comprueba: si esa llamada alguna vez avisara, el aviso sería una excepción y el test fallaría. Los casos en silencio no necesitan código especial.
Un ejemplo completo
El proyecto shop con cada regla en un solo sitio. pyproject.toml:
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]
log_cli_level = "INFO"src/shop/pricing.py:
import logging
import warnings
log = logging.getLogger(__name__)
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
log.info("discount %d%% on %.2f", percent, total)
return round(total * (100 - percent) / 100, 2)$ PYTHONPATH=src python -c "from shop.pricing import apply_discount; print(apply_discount(200.0, 10))"
180.0tests/test_pricing.py — el plan del principio del capítulo, fila por fila:
import pytest
from shop.pricing import apply_discount, line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name_is_deprecated():
with pytest.deprecated_call(match="use line_total"):
assert total_with_vat(100.0, 1) == 115.0
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large") as record:
assert apply_discount(200.0, 60) == 80.0
assert len(record) == 1
def test_fifty_percent_is_quiet():
assert apply_discount(200.0, 50) == 100.0
@pytest.mark.slow
def test_many_lines():
total = sum(line_total(1.0, 1) for _ in range(10_000))
assert round(total, 2) == 11500.0
@pytest.mark.xfail(reason="bug #41: zero quantity should be free, not an error")
def test_zero_quantity():
assert line_total(15.0, 0) == 0.0$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_pricing.py .....x [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
========================= 5 passed, 1 xfailed in 0.01s =========================
$ pytest -m "not slow" -o log_cli=true
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items / 1 deselected / 5 selected
tests/test_pricing.py::test_line_total PASSED [ 20%]
tests/test_pricing.py::test_old_name_is_deprecated PASSED [ 40%]
tests/test_pricing.py::test_large_discount_warns
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 60% on 200.00
PASSED [ 60%]
tests/test_pricing.py::test_fifty_percent_is_quiet
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 50% on 200.00
PASSED [ 80%]
tests/test_pricing.py::test_zero_quantity XFAIL (bug #41: zero quant...) [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
================== 4 passed, 1 deselected, 1 xfailed in 0.01s ==================Tres cosas que vale la pena notar.
Primero, nada en ninguno de los dos comandos es una regla del proyecto. Las reglas —dónde están los tests, qué es importable, marcadores estrictos, xfail estricto, avisos como errores— están todas en el archivo, así que el compañero que escribe pytest a secas obtiene exactamente la primera ejecución, y CI también.
Segundo, no hay resumen de avisos. Los dos avisos que emite el código son esperados, así que warns y deprecated_call los consumieron; cualquier otro aviso habría hecho fallar un test. Una ejecución limpia es ahora realmente limpia.
Tercero, test_fifty_percent_is_quiet no contiene código de avisos, y sin embargo protege la frontera: bajo error, un aviso al 50% lo haría fallar.
Cuando algo falla
ModuleNotFoundError: No module named 'shop' durante la recopilación El código no es importable desde los tests. Añade pythonpath = ["src"] a la configuración, y comprueba la línea configfile: del encabezado para asegurarte de que ese es el archivo que se está leyendo.
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!) Dos archivos de configuración, y el que editaste perdió. No se fusiona nada. Borra uno.
'slow' not found in markers configuration option --strict-markers (o strict = true) está activo, y el marcador no está registrado o está mal escrito. Regístralo bajo markers, o corrige la ortografía.
PytestConfigWarning: Unknown config option: testpath Un ajuste mal escrito —testpath por testpaths—. pytest solo avisa, así que el ajuste no hace nada en silencio. Con strict_config = true (o strict = true) se convierte en ERROR: Unknown config option: testpath y la ejecución se detiene, que es lo que quieres.
ERROR: ... Cannot use both [tool.pytest] (native TOML types) and [tool.pytest.ini_options] (string-based INI format) simultaneously. pyproject.toml tiene ambas tablas. Mueve todo a una sola.
TypeError: ... config option 'addopts' expects a list for type 'args', got str: '-ra --strict-markers' En la tabla nativa [tool.pytest] addopts es una lista: addopts = ["-ra", "--strict-markers"]. La forma de cadena pertenece a [tool.pytest.ini_options].
ERROR: ... 'minversion' requires pytest-10.0, actual pytest-9.1.1' El pytest instalado es más viejo que el que necesita el proyecto. Actualízalo dentro del entorno virtual.
Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted. Nada de esa categoría se emitió en el bloque. Lee Emitted warnings: debajo —a menudo el código avisa con una categoría distinta, o no con esta entrada—.
Failed: Regex pattern did not match any of the 1 warnings emitted. Categoría correcta, texto equivocado. match es una expresión regular, así que (, . y ? necesitan escaparse —o envuelve el texto en re.escape().
AttributeError: module 'builtins' has no attribute 'DeprecatedWarning'. Did you mean: 'DeprecationWarning'? Una errata en la categoría de una línea de filterwarnings. pytest se niega a arrancar e imprime justo encima la línea que no pudo analizar.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Esto se ejecuta con pytest -q -s. ¿Qué número imprime el test?
import warnings
import pytest
def check(n):
if n < 0:
warnings.warn("negative value", UserWarning)
return abs(n)
def test_record():
with pytest.warns(UserWarning) as record:
check(-1)
check(2)
check(-3)
print(len(record))- A3
- B0
- C2
- D1
En la raíz del proyecto hay un pytest.ini y un pyproject.toml con [tool.pytest.ini_options]. ¿Qué ocurre cuando ejecutas pytest?
- Apytest se detiene con un error por tener dos archivos de configuración
- BSe usa `pytest.ini`, los ajustes de `pyproject.toml` se ignoran, y el encabezado avisa de ello
- C`pyproject.toml` gana porque es el estándar más nuevo
- DLos dos archivos se fusionan, y `pyproject.toml` completa lo que falta
La configuración tiene addopts = -ra --strict-markers y solo registra slow bajo markers. ¿Qué ocurre en pytest -m "not slow"?
import pytest
@pytest.mark.slwo
def test_every_day_of_a_year():
fees = [day * 5 for day in range(366)]
assert fees[-1] == 1825- AEl test se ejecuta, con un aviso en el resumen
- BEl test se deselecciona, como se pretendía
- CLa recopilación se detiene: 'slwo' not found in `markers` configuration option
- DEl test se omite
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
El proyecto de la biblioteca cobra una tarifa por retraso. src/library/fees.py:
import warnings
PER_DAY = 5
def late_fee(days):
if days < 0:
raise ValueError("days cannot be negative")
if days > 60:
warnings.warn(f"{days} days late looks like a lost book", UserWarning)
return days * PER_DAY
def fine(days):
warnings.warn(
"fine() is deprecated; use late_fee()",
DeprecationWarning,
stacklevel=2,
)
return late_fee(days)$ PYTHONPATH=src python -c "from library.fees import late_fee; print(late_fee(4))"
20- Escribe la tabla
[tool.pytest.ini_options]de modo que unpytesta secas, desde la raíz o desde dentro detests/, importelibrary, recopile solotests/, imprima los resúmenes-ra, rechace los marcadores desconocidos, falle ante un pasexfailinesperado y convierta cada aviso inesperado en un error. Registra un marcadorslow. - Escribe primero el plan de tests —caso, entrada, esperado—, cubriendo la devolución puntual, la frontera de los 60 días, 61 días, un número negativo y la
fine()obsoleta. - Escribe
tests/test_fees.pya partir del plan, con un testslowque recorra cada día de un año. - Ejecuta
pytest, luegopytest -m "not slow", luegocd tests && pytest.
Luego dos experimentos, prediciendo primero cada resultado: sustituye el test de deprecated_call por una llamada ordinaria a fine(3), y escribe mal slow como slwo.
Solución
El plan:
| Caso | Entrada | Esperado | | --- | --- | --- | | devolución puntual | late_fee(0) | 0, sin aviso | | frontera, todavía en silencio | late_fee(60) | 300, sin aviso | | justo más allá de la frontera | late_fee(61) | 305 y un UserWarning que menciona "lost book" | | entrada inválida | late_fee(-1) | ValueError que menciona "negative" | | nombre obsoleto | fine(3) | 15 y un DeprecationWarning que menciona late_fee | | un año entero (slow) | late_fee(0) … late_fee(365) | última tarifa 1825, avisos esperados |
pyproject.toml:
[project]
name = "library"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]tests/test_fees.py:
import pytest
from library.fees import fine, late_fee
def test_returned_on_time():
assert late_fee(0) == 0
def test_sixty_days_is_quiet():
# filterwarnings = error: any warning here would fail this test
assert late_fee(60) == 300
def test_sixty_one_days_warns():
with pytest.warns(UserWarning, match="lost book") as record:
assert late_fee(61) == 305
assert len(record) == 1
def test_negative_days_rejected():
with pytest.raises(ValueError, match="negative"):
late_fee(-1)
def test_fine_is_deprecated():
with pytest.deprecated_call(match="use late_fee"):
assert fine(3) == 15
@pytest.mark.slow
def test_every_day_of_a_year():
with pytest.warns(UserWarning):
fees = [late_fee(day) for day in range(366)]
assert fees[-1] == 1825$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/library
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_fees.py ...... [100%]
============================== 6 passed in 0.01s ===============================
$ pytest -m "not slow" -q
..... [100%]
5 passed, 1 deselected in 0.01s
$ cd tests && pytest -q
...... [100%]
6 passed in 0.01sLos dos experimentos:
$ pytest -q # test_fine_still_works calls fine(3) with no deprecated_call
=========================== short test summary info ============================
FAILED tests/test_fees.py::test_fine_still_works - DeprecationWarning: fine()...
1 failed, 5 passed in 0.01s
$ pytest -q # @pytest.mark.slwo
ERROR tests/test_fees.py - Failed: 'slwo' not found in `markers` configuratio...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sEl porqué de cada decisión:
pythonpathytestpathsen el archivo, no en la línea de comandos. Por esocd tests && pytestda los mismos seis pases: ambos son relativos a la rootdir, que pytest encuentra subiendo hastapyproject.toml.- Solo reglas en
addopts.-ray--strict-markersson políticas que todos comparten;-vo-xson hábitos personales y se quedan en tu propia línea de comandos. filterwarnings = ["error"]sin excepciones. El proyecto todavía no tiene avisos de terceros, así que no hay nada que excusar. El día que aparezca uno, añade una línea estrechaignore::...:module, nunca unignoregeneral.- El test de los 60 días no tiene código de avisos. Bajo
error, cualquier aviso con 60 días lo haría fallar, así que la configuración protege la frontera. matchen cada comprobación dirigida a un mensaje. "lost book" y "use late_fee" son las palabras sobre las que actúa un lector; el resto se puede reformular libremente. El test del año dejamatchfuera a propósito: trata sobre las tarifas, y solo tiene que decir que allí se esperan avisos.recorden el test de los 61 días. Demuestra que una llamada avisa una vez, no en cada paso interno.- Los experimentos. Sin
deprecated_call, la obsolescencia es inesperada, así queerrorla convierte en un fallo —exactamente lo que CI debería hacer cuando alguien llama a una API antigua—. El marcador mal escrito detiene la recopilación porque--strict-markersestá enaddopts. (Aquí el filtroerrortambién lo habría detectado, porque un marcador desconocido emite unPytestUnknownMarkWarning—pero la regla del marcador no debería depender de la política de avisos, así que ambas se quedan—.)
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