Tests asíncronos, hooks y escribir un plugin
Probar código asíncrono con pytest-asyncio, luego extender pytest con hooks en conftest.py —una bandera --runslow, un marcador, una cabecera de informe— y finalmente convertir eso en un plugin propio y probarlo con pytester.
¿Cómo quieres aprender?
El problema que vamos a resolver
Aquí tienes un pequeño fragmento de código asíncrono —el tipo que habla con la red y no quiere quedarse ocioso mientras espera.
prices.py:
import asyncio
PRICES = {"pen": 15.0, "notebook": 60.0}
async def fetch_price(item):
await asyncio.sleep(0.01) # stands in for a network call
if item not in PRICES:
raise KeyError(item)
return PRICES[item]Lo pruebas como has probado todo hasta ahora: una función cuyo nombre empieza por test_. Es una función async, porque await solo está permitido dentro de una. Y para asegurarte de que el test está comprobando algo de verdad, el precio esperado está mal a propósito:
test_prices.py:
from prices import fetch_price
async def test_pen_price():
assert await fetch_price("pen") == 999.0pytest -q:
F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
async def functions are not natively supported.
You need to install a suitable plugin for your async framework, for example:
- anyio
- pytest-asyncio
- pytest-tornasync
- pytest-trio
- pytest-twisted
=========================== short test summary info ============================
FAILED test_prices.py::test_pen_price - Failed: async def functions are not n...
1 failed in 0.11sLee el fallo con atención. No dice nada de 15.0 ni de 999.0. La aserción nunca se ejecutó. Llamar a una función async def no ejecuta su cuerpo —solo crea una corrutina, y algo tiene que impulsar un bucle de eventos para ejecutarla. pytest a secas no lo hace. (Las versiones antiguas de pytest omitían tal test con una advertencia, así que parecía verde sin probar nada. pytest 9 lo hace fallar, lo cual es mucho mejor.)
Esa es la primera mitad de este capítulo: lograr que pytest ejecute código asíncrono. La segunda mitad es la idea más profunda detrás de la solución —pytest es en sí un conjunto de hooks, y un plugin no es más que código que rellena algunos de ellos. Una vez que ves eso, puedes escribir los tuyos: una marca --runslow, un marcador, una línea en la cabecera del informe y una suite de tests para el plugin.
Al terminar este capítulo podrás
- Explicar por qué un
async def test_...a secas no se ejecuta, y reconocer el fallo - Ejecutar tests asíncronos con
pytest-asyncio, usando@pytest.mark.asynciooasyncio_mode = "auto" - Escribir fixtures asíncronas, incluidas las que tienen limpieza después de
yield - Añadir una opción de línea de comandos con
pytest_addoptiony leerla conrequest.config.getoption - Saltar tests lentos a menos que se dé
--runslow, usandopytest_collection_modifyitems - Registrar un marcador en
pytest_configurey añadir una línea conpytest_report_header - Decir dónde busca pytest los hooks:
conftest.py,-p, y puntos de entradapytest11 - Probar un plugin con la fixture
pytester
Requisitos previos: cobertura y CI.
Antes de escribir el test
Este capítulo prueba dos tipos de cosa distintos —una función asíncrona y un plugin de pytest— y ambas necesitan algo de reflexión antes de la primera línea de test.
El contrato. Escribe el contrato en palabras llanas primero, porque los tests son ese contrato, traducido.
fetch_price(item)es una función corrutina. Esperada con un artículo conocido, da su precio. Esperada con uno desconocido, lanzaKeyError. Muchas llamadasawaita la vez deben solaparse, no ponerse en cola.- El plugin de tests lentos promete: sin
--runslow, todo test marcadoslowse salta, con una razón; con--runslow, todo se ejecuta; la cabecera dice cuál; y el marcadorslowestá registrado, así que no lanza advertencia.
Qué debe estar listo. Los tests asíncronos necesitan un runner, que pytest no tiene: instala pytest-asyncio y decide modo estricto o auto antes de escribir tests, porque los decoradores que escribas dependen de él. El código bajo test ha de ser importable —prices.py en la raíz del proyecto, y más adelante también el módulo del plugin (pythonpath = ["."]). Probar el plugin necesita la fixture pytester activada. Y nada debe requerir red real: asyncio.sleep hace las veces de una.
El plan. Una fila por comportamiento, cada una con su entrada y lo que esperas ver:
| Caso | Entrada | Esperado | | --- | --- | --- | | artículo conocido | await fetch_price("pen") | 15.0 | | artículo desconocido | await fetch_price("stapler") | KeyError | | falta await | fetch_price("pen") en un test normal | un objeto corrutina, no un precio | | muchos a la vez | 400 llamadas con asyncio.gather | las 400 vuelven, en el tiempo de una | | ejecución por defecto | pytest con un test slow | 1 passed, 1 skipped | | bandera presente | pytest --runslow | 2 passed | | cabecera | pytest | una línea slow tests: skipped (use --runslow) | | marcador | pytest --markers | slow listado, sin PytestUnknownMarkWarning |
Qué no probar. Ni asyncio.sleep, ni el bucle de eventos —Python los prueba. Ni el mecanismo de saltos de la propia pytest: pruebas que tu hook pone el salto, contando resultados, no cómo imprime pytest una s. Ni tiempos exactos, tampoco: "400 llamadas terminan en menos de un segundo" es una afirmación estable en cualquier máquina, mientras que "en 0.013 segundos" no lo es.
El resto del capítulo recorre esa tabla, fila a fila.
Ejecutando tests asíncronos con pytest-asyncio
El mensaje de fallo lista varios plugins. Para código construido sobre asyncio, la elección habitual es pytest-asyncio:
pip install pytest-asyncioInstalarlo no basta por sí solo. Vuelve a ejecutar el mismo test y la salida es idéntica, palabra por palabra: async def functions are not natively supported. Por defecto pytest-asyncio trabaja en modo estricto —solo toca tests que le hayas entregado explícitamente. Le entregas uno con un marcador:
import pytest
from prices import fetch_price
@pytest.mark.asyncio
async def test_pen_price():
assert await fetch_price("pen") == 999.0F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
@pytest.mark.asyncio
async def test_pen_price():
> assert await fetch_price("pen") == 999.0
E assert 15.0 == 999.0
test_prices.py:8: AssertionError
=========================== short test summary info ============================
FAILED test_prices.py::test_pen_price - assert 15.0 == 999.0
1 failed in 0.10sAhora falla por la razón correcta. assert 15.0 == 999.0 —la corrutina se ejecutó, await produjo un número real, y la aserción lo comparó. Ver un test fallar con el valor equivocado es la prueba de que se está ejecutando de verdad. Arregla el número, y añade un test para el camino de error mientras estás en ello:
import pytest
from prices import fetch_price
@pytest.mark.asyncio
async def test_pen_price():
assert await fetch_price("pen") == 15.0
@pytest.mark.asyncio
async def test_unknown_item():
with pytest.raises(KeyError):
await fetch_price("stapler").. [100%]
2 passed in 0.10spytest.raises funciona dentro de un test asíncrono exactamente igual que en cualquier otro lado; el await simplemente va dentro del bloque with.
Hay una forma equivocada más que vale la pena ver, porque es la más común: evitar toda la cuestión escribiendo un test def normal y llamando a la función asíncrona sin await.
from prices import fetch_price
def test_pen_price():
assert fetch_price("pen") == 15.0F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
def test_pen_price():
> assert fetch_price("pen") == 15.0
E AssertionError: assert <coroutine object fetch_price at 0x70659d650e80> == 15.0
E + where <coroutine object fetch_price at 0x70659d650e80> = fetch_price('pen')
test_noawait.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_noawait.py::test_pen_price - AssertionError: assert <coroutine ob...
1 failed in 0.08s
sys:1: RuntimeWarning: coroutine 'fetch_price' was never awaitedEl informe dice exactamente lo que pasó: fetch_price('pen') devolvió un objeto corrutina, y una corrutina no es igual a 15.0. La última línea es Python avisando de que la corrutina se tiró sin ejecutarse nunca. Esta al menos falla ruidosamente. La variante peligrosa es assert fetch_price("pen") sin comparación —un objeto corrutina es truthy, así que ese test reporta 1 passed, 1 warning sin comprobar nada. Cualquier test de código asíncrono debe ser async def y debe await.
asyncio_mode = "auto" y fixtures asíncronas
Escribir @pytest.mark.asyncio encima de cada test cansa rápido. En un proyecto donde lo asíncrono es lo normal, cambia el modo en pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto"En modo auto, pytest-asyncio toma todos los tests async def y todas las fixtures async def, sin marcador. La cabecera de una ejecución pytest completa te dice qué modo está en vigor:
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=functionLas fixtures también pueden ser asíncronas. Aquí se prepara un carrito, se pasa al test, y se cierra después —con un await a ambos lados del yield:
import pytest
from prices import fetch_price
class Cart:
def __init__(self):
self.items = []
self.closed = False
async def add(self, item):
self.items.append((item, await fetch_price(item)))
async def close(self):
self.closed = True
@pytest.fixture
async def cart():
c = Cart()
yield c
await c.close()
async def test_add(cart):
await cart.add("pen")
assert cart.items == [("pen", 15.0)]
async def test_two_items(cart):
await cart.add("pen")
await cart.add("notebook")
assert [price for _, price in cart.items] == [15.0, 60.0].. [100%]
2 passed in 0.11sEl yield funciona como en el capítulo de fixtures: todo lo que va antes es preparación, todo lo que va después es limpieza, y la limpieza se ejecuta incluso cuando el test falla.
En modo estricto, la misma fixture necesita su propio decorador, @pytest_asyncio.fixture, de import pytest_asyncio. Con un @pytest.fixture plano ahí, pytest no sabe cómo ejecutarla y se para en la preparación:
E [100%]
==================================== ERRORS ====================================
__________________________ ERROR at setup of test_add __________________________
'test_add' requested an async fixture 'cart', with no plugin or hook that handled it. This is an error, as pytest does not natively support it.
See: https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture
=========================== short test summary info ============================
ERROR test_cart.py::test_add - Failed: 'test_add' requested an async fixture ...
1 error in 0.09sAsí que la elección es simple. Modo estricto: marca tests asíncronos con @pytest.mark.asyncio y fixtures asíncronas con @pytest_asyncio.fixture. Modo auto: async def plano en todas partes. Elige uno por proyecto y escríbelo en la configuración para que nadie tenga que adivinar.
¿Cómo lo hizo pytest-asyncio? No cambió pytest. Implementó unos cuantos de los hooks de pytest —funciones con nombres fijos que pytest llama en momentos fijos, como "un test está a punto de ejecutarse". Eso es todo lo que es un plugin. El resto de este capítulo escribe un plugin propio.Hooks en conftest.py
Un hook es una función llamada pytest_<algo> que pytest llama en un momento concreto de una ejecución. No la registras; la defines en un sitio donde pytest mira, y el nombre hace el resto. El primer sitio así es el archivo conftest.py que ya usas para fixtures compartidas.
Una opción de línea de comandos: pytest_addoption
Supongamos que los mismos tests deben ejecutarse contra tu portátil y contra un servidor de staging. La dirección pertenece a la línea de comandos, no al código:
conftest.py:
import pytest
def pytest_addoption(parser):
parser.addoption(
"--shop-url", default="http://localhost:8000", help="where the shop under test runs"
)
@pytest.fixture
def shop_url(request):
return request.config.getoption("--shop-url")test_shop.py:
def test_shop_url(shop_url):
print("testing against", shop_url)
assert shop_url.startswith("http")pytest -q -s, luego pytest -q -s --shop-url https://staging.example.com:
testing against http://localhost:8000
.
1 passed in 0.07s
testing against https://staging.example.com
.
1 passed in 0.07spytest_addoption se ejecuta una vez, antes de analizar la línea de comandos, y parser.addoption toma los mismos argumentos que argparse de Python. Luego request.config es el objeto de configuración de la ejecución, y getoption recupera el valor. La opción también aparece en pytest --help:
--shop-url=SHOP_URL where the shop under test runsEncapsular la opción en una fixture es el patrón usual: los tests piden shop_url y nunca tocan la configuración directamente.
Saltando tests lentos: pytest_collection_modifyitems
Una necesidad común: algunos tests tardan segundos, y no los quieres en cada guardado —solo antes de un push, o en CI. Márcalos slow, y sáltalos a menos que se dé una bandera --runslow.
El primer intento tentador es poner la comprobación dentro de cada test lento: lee la opción al principio, llama a pytest.skip() si falta. Funciona, y es la forma equivocada. La regla está ahora en cada test lento en vez de en un sitio; el día que alguien escribe un test lento nuevo y se olvida de esas dos líneas, se ejecuta en cada guardado y nadie nota por qué la suite se volvió más lenta. Y el cuerpo del test tiene que empezar a ejecutarse antes de poder decidir no ejecutarse, así que sus fixtures ya están preparadas —quizá una base de datos, quizá un servidor.
El sitio correcto es antes de que empiece ningún test. Después de que pytest ha recogido todos los tests, y antes de que ninguno se ejecute, llama a pytest_collection_modifyitems(config, items). items es la lista de tests recogidos, y el hook puede cambiarla —una regla, en un sitio, aplicada a todo test que lleve el marcador.
conftest.py:
import pytest
def pytest_addoption(parser):
parser.addoption(
"--runslow", action="store_true", default=False, help="also run tests marked slow"
)
def pytest_configure(config):
config.addinivalue_line("markers", "slow: a test that takes seconds, not milliseconds")
def pytest_report_header(config):
if config.getoption("--runslow"):
return "slow tests: included"
return "slow tests: skipped (use --runslow)"
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
return
skip_slow = pytest.mark.skip(reason="needs --runslow")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)test_reports.py:
import time
import pytest
def test_quick_total():
assert sum([15.0, 60.0]) == 75.0
@pytest.mark.slow
def test_full_year_report():
time.sleep(1) # pretend this crunches a year of sales
assert Truepytest -q, luego pytest -q --runslow:
.s [100%]
1 passed, 1 skipped in 0.10s
.. [100%]
2 passed in 1.09sSin la bandera, el test lento es una s y la ejecución tarda una décima de segundo. Con ella, ambos se ejecutan y el segundo cuesta su segundo completo. action="store_true" hace de --runslow un interruptor: ausente significa False, presente significa True. Dentro de un hook no hay request, así que el hook lee el valor directo de config.
item.keywords guarda los nombres de los marcadores del test (entre otras cosas), así que "slow" in item.keywords pregunta "¿lleva este test @pytest.mark.slow?". item.add_marker(skip_slow) le adjunta un salto, exactamente como si hubieras escrito @pytest.mark.skip(reason="needs --runslow") encima del test.
Registrando el marcador y añadiendo una línea de cabecera
Dos hooks más pequeños en ese archivo merecen una palabra.
pytest_configure(config) se ejecuta una vez, después de analizar la línea de comandos. config.addinivalue_line("markers", ...) registra el marcador slow —lo mismo que una línea markers = [...] en pyproject.toml, pero incluido en el propio plugin. Sin ello, cada @pytest.mark.slow produce PytestUnknownMarkWarning, y bajo --strict-markers se vuelve error. Ahora pytest --markers lo lista primero:
@pytest.mark.slow: a test that takes seconds, not millisecondspytest_report_header(config) devuelve una cadena (o lista de cadenas) que pytest imprime al principio de una ejecución. Es el sitio correcto para declarar cualquier cosa que el lector de un log de CI necesite saber. pytest -rs:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
slow tests: skipped (use --runslow)
rootdir: /home/you/shop
collected 2 items
test_reports.py .s [100%]
=========================== short test summary info ============================
SKIPPED [1] test_reports.py:10: needs --runslow
========================= 1 passed, 1 skipped in 0.07s =========================Un test saltado con una razón clara es honesto: el resumen dice qué no se ejecutó, y por qué.
Cómo encuentra pytest los hooks
Cada uno de estos hooks vive en un módulo. pytest recoge esos módulos —plugins— de varios sitios:
- Sus propios plugins integrados. La mayor parte de pytest está escrita como plugins:
-k, marcadores,tmp_path, el informe de terminal. - Paquetes instalados que declaran un punto de entrada
pytest11. Así es comopytest-asyncio,pytest-covy los demás se cargan solos en cuanto se instalan. La líneaplugins:de la cabecera los lista. - Módulos nombrados con
-pen la línea de comandos, o enaddopts. - Archivos
conftest.py, en la raíz de tus tests y en cualquier subdirectorio (elconftest.pyde un subdirectorio solo aplica a tests debajo de él). Unos pocos hooks tempranos, comopytest_addoption, solo se respetan en unconftest.pyde la raíz.
Así que el conftest.py de arriba se puede extraer tal cual. Renómbralo a pytest_slowtests.py, y es un plugin. Cárgalo con -p y el nombre del módulo:
pytest -p pytest_slowtestsEl módulo tiene que ser importable. Si está en la raíz del proyecto, indícalo a pytest con pythonpath, y para no teclear -p cada vez, ponlo en addopts:
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "-p pytest_slowtests"Para compartirlo entre proyectos, hazlo un paquete y declara el punto de entrada. El grupo debe llamarse pytest11; el nombre de la izquierda es el nombre del plugin, y el valor de la derecha es el módulo:
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "pytest-slowtests"
version = "0.1.0"
dependencies = ["pytest>=8"]
[project.entry-points.pytest11]
slowtests = "pytest_slowtests"Instálalo (pip install ./pytest-slowtests) en un entorno fresco, y en un proyecto sin conftest.py alguno:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
slow tests: skipped (use --runslow)
rootdir: /home/you/other-project
plugins: slowtests-0.1.0
collected 2 items
test_reports.py .s [100%]
========================= 1 passed, 1 skipped in 0.01s =========================plugins: slowtests-0.1.0 —pytest lo encontró a través del punto de entrada. Lo contrario también funciona: -p no:slowtests apaga un plugin instalado para una ejecución, lo que sirve cuando sospechas que un plugin causa problemas.
Probando un plugin con pytester
Un plugin cambia cómo se comporta pytest, así que sus tests tienen que ejecutar pytest y mirar qué pasó. pytest trae una fixture para justo eso, pytester. Viene apagada por defecto; actívala en el módulo de tests (o en el conftest.py de la raíz):
pytest_plugins = ["pytester"]pytester da a cada test un directorio temporal vacío, un miniproyecto fresco. pytester.makepyfile(...) escribe un archivo de test en él, pytester.runpytest(...) ejecuta pytest allí con los argumentos que des, y el resultado tiene assert_outcomes(passed=..., skipped=..., failed=...) para comprobar las cuentas, y result.stdout.fnmatch_lines([...]) para comprobar líneas de salida (* sirve de comodín). La versión completa está en el ejemplo completo de abajo.
Un ejemplo completo
Un pequeño proyecto que reúne todo lo de este capítulo: código asíncrono, tests asíncronos en modo auto, el plugin de tests lentos, y tests para el plugin.
shop/
├── pyproject.toml
├── prices.py
├── pytest_slowtests.py
└── tests/
├── test_plugin.py
└── test_prices.pyprices.py es el del principio del capítulo, y pytest_slowtests.py es el conftest.py de la sección --runslow, renombrado. pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
addopts = "-p pytest_slowtests"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"tests/test_prices.py:
import asyncio
import pytest
from prices import PRICES, fetch_price
async def test_pen_price():
assert await fetch_price("pen") == 15.0
async def test_unknown_item():
with pytest.raises(KeyError):
await fetch_price("stapler")
@pytest.mark.slow
async def test_every_price_at_once():
names = list(PRICES) * 200
prices = await asyncio.gather(*(fetch_price(n) for n in names))
assert len(prices) == 400tests/test_plugin.py:
pytest_plugins = ["pytester"]
SAMPLE = """
import pytest
def test_quick():
assert True
@pytest.mark.slow
def test_slow():
assert True
"""
def run(pytester, *args):
# The inner run is a brand-new project in a temporary directory.
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function\n")
pytester.makepyfile(SAMPLE)
return pytester.runpytest("-p", "pytest_slowtests", *args)
def test_slow_is_skipped_by_default(pytester):
result = run(pytester)
result.assert_outcomes(passed=1, skipped=1)
def test_runslow_runs_everything(pytester):
result = run(pytester, "--runslow")
result.assert_outcomes(passed=2)
def test_header_mentions_the_flag(pytester):
result = run(pytester)
result.stdout.fnmatch_lines(["slow tests: skipped (use --runslow)"])pytest -v (cabecera abreviada):
slow tests: skipped (use --runslow)
configfile: pyproject.toml
testpaths: tests
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function
collecting ... collected 6 items
tests/test_plugin.py::test_slow_is_skipped_by_default PASSED [ 16%]
tests/test_plugin.py::test_runslow_runs_everything PASSED [ 33%]
tests/test_plugin.py::test_header_mentions_the_flag PASSED [ 50%]
tests/test_prices.py::test_pen_price PASSED [ 66%]
tests/test_prices.py::test_unknown_item PASSED [ 83%]
tests/test_prices.py::test_every_price_at_once SKIPPED (needs --runslow) [100%]
========================= 5 passed, 1 skipped in 0.17s =========================Y pytest -q --runslow:
...... [100%]
6 passed in 0.40sHay tres cosas que vale la pena notar.
Primero, el plugin funciona en tests asíncronos también. test_every_price_at_once es un async def con un marcador slow, y el salto se le aplica como a cualquier otro test. Los hooks ven items recogidos; si la función detrás de uno es asíncrona es asunto de pytest-asyncio, no de ellos.
Segundo, asyncio.gather ejecutó 400 llamadas a fetch_price, cada una durmiendo 0.01 segundos, y la ejecución completa tardó bien menos de un segundo —no cuatro. Esperaron a la vez. Esa es la razón de ser del código asíncrono, y el test demuestra que se comporta así.
Tercero, la línea makeini en run. La ejecución interna dentro de pytester es un proyecto aparte con su propia configuración, y carga todos los plugins instalados —incluyendo pytest-asyncio, que avisa cuando asyncio_default_fixture_loop_scope no está fijado. Darle a ese proyecto interno ese único ajuste mantiene limpio el informe exterior. La lección general: una ejecución pytester ve tus plugins instalados, pero no tu pyproject.toml.
Cuando el plugin se rompe, estos tests lo dicen con precisión. Aquí la comprobación de salto se escribió mal como "slw", así que nada se saltó:
> result.assert_outcomes(passed=1, skipped=1)
E AssertionError: assert {'passed': 2,...rors': 0, ...} == {'passed': 1,...rors': 0, ...}
E
E Omitting 4 identical items, use -vv to show
E Differing items:
E {'passed': 2} != {'passed': 1}
E {'skipped': 0} != {'skipped': 1}
E Use -v to get more diffDebajo, bajo Captured stdout call, pytest imprime la salida completa de la ejecución interna, así que ves exactamente qué hizo.
Cuando algo falla
async def functions are not natively supported. El test es un async def, y nada lo está ejecutando. O pytest-asyncio no está instalado, o está en modo estricto y al test le falta @pytest.mark.asyncio. Añade el marcador, o pon asyncio_mode = "auto".
requested an async fixture 'cart', with no plugin or hook that handled it Una fixture async def está decorada con @pytest.fixture plano en modo estricto. Usa @pytest_asyncio.fixture, o cambia a modo auto.
AssertionError: assert <coroutine object fetch_price at 0x...> == 15.0, seguido de RuntimeWarning: coroutine 'fetch_price' was never awaited Un test def normal llamó a una función asíncrona sin await, y comparó el objeto corrutina en sí. Haz el test async def y escribe await fetch_price("pen").
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? El marcador se usa pero nunca se registra. Regístralo en pytest_configure con config.addinivalue_line("markers", ...), o en el ajuste markers.
PluginValidationError: unknown hook 'pytest_addoptions' in plugin <module 'conftest' ...> Una función empieza por pytest_ pero no es un hook que pytest conozca —aquí, una s de más. pytest comprueba cada nombre pytest_ en un plugin, que es justo lo que pilla el error tipográfico. Comprueba la ortografía contra la referencia de hooks.
ValueError: no option named '--runslow' config.getoption pidió una opción que ningún pytest_addoption añadió. O el nombre es distinto, o el hook pytest_addoption vive en un conftest.py que pytest no cargó lo bastante pronto —mantenlo en la raíz de tus tests.
pytest: error: unrecognized arguments: --run-slow La opción en la línea de comandos no coincide con la registrada. pytest --help lista todas las opciones que tus plugins han añadido, bajo "custom options".
ImportError: Error importing plugin "pytest_slowtests": No module named 'pytest_slowtests' -p toma un nombre de módulo, y ese módulo no se pudo importar. Añade pythonpath = ["."] a la config, o instala el plugin como paquete.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
fetch_price("pen") realmente devuelve 15.0. ¿Qué reporta pytest -q?
# pytest-asyncio is installed, in its default (strict) mode
from prices import fetch_price
async def test_pen_price():
assert await fetch_price("pen") == 999.0- A1 skipped, 1 warning
- B1 failed — async def functions are not natively supported.
- C1 failed — assert 15.0 == 999.0
- D1 passed
En modo estricto este test da ERROR en la preparación. ¿Cuál es la solución?
# pyproject.toml has no asyncio_mode, so pytest-asyncio is in strict mode
import pytest
from prices import fetch_price
class Cart:
def __init__(self):
self.items = []
async def add(self, item):
self.items.append((item, await fetch_price(item)))
@pytest.fixture
async def cart():
yield Cart()
@pytest.mark.asyncio
async def test_add(cart):
await cart.add("pen")
assert cart.items == [("pen", 15.0)]- AReemplazar `yield` por `return`
- BDecorar la fixture con `@pytest_asyncio.fixture` (o poner `asyncio_mode = "auto"`)
- CQuitar `@pytest.mark.asyncio` del test
- DHacer `test_add` un test `def` normal
Ambos archivos están en una carpeta. ¿Qué muestra pytest -q, sin banderas?
# conftest.py
import pytest
def pytest_addoption(parser):
parser.addoption("--runslow", action="store_true", default=False)
def pytest_configure(config):
config.addinivalue_line("markers", "slow: a slow test")
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
return
skip_slow = pytest.mark.skip(reason="needs --runslow")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)
# test_reports.py
def test_quick_total():
assert sum([15.0, 60.0]) == 75.0
@pytest.mark.slow
def test_full_year_report():
assert True- As 1 skipped
- B.F 1 passed, 1 failed
- C.. 2 passed
- D.s 1 passed, 1 skipped
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
Escribe un plugin, pytest_budget.py, que dé a cada test un presupuesto de tiempo.
- Añade una opción
--budgetcon valor por defecto0.5(segundos); usatype=floatenparser.addoption. - Proporciona una fixture
budgetque devuelva el valor víarequest.config.getoption("--budget"). - Registra un marcador
networkenpytest_configure, e imprimebudget: 0.5s(o lo que se dé) conpytest_report_header. - En
pytest_collection_modifyitems, salta todo test marcadonetworkcuando se dé una segunda opción,--offline. - Cárgalo con
-p pytest_budget, y escribe un test asíncrono en modo auto queawait asyncio.sleep(...)por menos debudgety asegura que termine a tiempo.
Luego prueba el plugin con pytester: un test para cada opción, usando assert_outcomes para las cuentas y fnmatch_lines para la cabecera. Finalmente, rompe el plugin a propósito —renombra pytest_report_header a pytest_report_headers— y lee lo que dice pytest. Ver ese error una vez hace que sea fácil reconocerlo para siempre.
Pruébalo tú antes de seguir leyendo.
Solución
El proyecto tiene la misma forma que el ejemplo completo:
budget/
├── pyproject.toml
├── pytest_budget.py
└── tests/
├── test_budget_plugin.py
└── test_timing.pypyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
addopts = "-p pytest_budget"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"pytest_budget.py:
"""Give every test a time budget, and let network tests be switched off."""
import pytest
def pytest_addoption(parser):
parser.addoption("--budget", type=float, default=0.5, help="seconds a test may take")
parser.addoption(
"--offline", action="store_true", default=False, help="skip tests marked network"
)
def pytest_configure(config):
config.addinivalue_line("markers", "network: the test needs a network connection")
def pytest_report_header(config):
return f"budget: {config.getoption('--budget')}s"
def pytest_collection_modifyitems(config, items):
if not config.getoption("--offline"):
return
skip_network = pytest.mark.skip(reason="--offline was given")
for item in items:
if "network" in item.keywords:
item.add_marker(skip_network)
@pytest.fixture
def budget(request):
return request.config.getoption("--budget")tests/test_timing.py:
import asyncio
import time
import pytest
async def test_finishes_within_budget(budget):
start = time.perf_counter()
await asyncio.sleep(budget / 5)
assert time.perf_counter() - start < budget
@pytest.mark.network
async def test_price_service_answers():
await asyncio.sleep(0.01) # stands in for a real request
assert Truetests/test_budget_plugin.py:
pytest_plugins = ["pytester"]
SAMPLE = """
import pytest
def test_reads_budget(budget):
assert budget == EXPECTED
@pytest.mark.network
def test_network():
assert True
"""
def run(pytester, *args, expected=0.5):
# A fresh inner project: its own config, our sample tests, our plugin.
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function\n")
pytester.makepyfile(SAMPLE.replace("EXPECTED", repr(expected)))
return pytester.runpytest("-p", "pytest_budget", *args)
def test_default_budget(pytester):
result = run(pytester)
result.assert_outcomes(passed=2)
def test_budget_option_is_a_float(pytester):
result = run(pytester, "--budget", "2", expected=2.0)
result.assert_outcomes(passed=2)
def test_offline_skips_network_tests(pytester):
result = run(pytester, "--offline")
result.assert_outcomes(passed=1, skipped=1)
def test_header_shows_budget(pytester):
result = run(pytester, "--budget", "1.5", expected=1.5)
result.stdout.fnmatch_lines(["budget: 1.5s"])
def test_marker_is_registered(pytester):
result = run(pytester, "--markers")
result.stdout.fnmatch_lines(["@pytest.mark.network: *"])pytest -v (cabecera abreviada):
budget: 0.5s
collecting ... collected 7 items
tests/test_budget_plugin.py::test_default_budget PASSED [ 14%]
tests/test_budget_plugin.py::test_budget_option_is_a_float PASSED [ 28%]
tests/test_budget_plugin.py::test_offline_skips_network_tests PASSED [ 42%]
tests/test_budget_plugin.py::test_header_shows_budget PASSED [ 57%]
tests/test_budget_plugin.py::test_marker_is_registered PASSED [ 71%]
tests/test_timing.py::test_finishes_within_budget PASSED [ 85%]
tests/test_timing.py::test_price_service_answers PASSED [100%]
============================== 7 passed in 0.31s ===============================pytest -q --offline -rs:
......s [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_timing.py:13: --offline was given
6 passed, 1 skipped in 0.57sY con el hook renombrado a pytest_report_headers, nada se ejecuta en absoluto:
INTERNALERROR> pluggy._manager.PluginValidationError: unknown hook 'pytest_report_headers' in plugin <module 'pytest_budget' from '/home/you/budget/pytest_budget.py'>
no tests ran in 0.00sEl porqué de cada decisión.
type=float. Sin él,--budget 2llega como la cadena"2", ybudget / 5lanzaTypeError.test_budget_option_is_a_floatexiste para fijar esto: el test interno asegurabudget == 2.0, y la cadena"2"no sería igual a él.- La fixture vive en el plugin. Un módulo de plugin puede definir fixtures igual que un
conftest.py, así que cualquiera que cargue-p pytest_budgetrecibebudgettambién. Los tests pidenbudget; ninguno sabe que hay una opción detrás. - El salto se decide en la recolección, no en el test.
test_price_service_answersno contieneif. Un hook tiene la regla, y un nuevo testnetworkqueda cubierto en cuanto se escribe. - El test de tiempo reclama un margen, no un número. Duerme una quinta parte del presupuesto y verifica que terminó dentro de todo el presupuesto. Eso funciona en una máquina de CI lenta; "tardó 0.1 segundos" no.
- Un helper
run. Cada testpytesterconstruye el mismo proyecto interno, así que la preparación —incluyendo la líneamakeinique silencia apytest-asyncioen la ejecución interna— se escribe una vez. El primer borrador de esta solución olvidó el helper entest_marker_is_registered, y la ejecución externa reportó enseguida1 warningde esa única ejecución interna. - Cuentas para comportamiento, líneas para salida.
assert_outcomescomprueba qué hace el plugin con los tests;fnmatch_linescomprueba qué imprime. El*en"@pytest.mark.network: *"evita que el test se rompa si se reescribe la descripción del marcador. - El hook roto. pytest comprueba cada función cuyo nombre empieza por
pytest_contra los hooks que conoce, y se niega a arrancar si uno no coincide. Un error tipográfico en un nombre de hook no puede fallar en silencio —y los testspytesterlo habrían detectado igual, ya quetest_header_shows_budgetbusca una línea que nunca se imprimiría.
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