Marcadores, skip y xfail — qué tests se ejecutan y dónde
Dile a pytest qué necesita cada test y qué esperas de él con skip, skipif, importorskip y xfail; qué significan strict=True, raises= y XPASS; registra tus propios marcadores y selecciona con -m; más fixtures parametrizadas y factory.
¿Cómo quieres aprender?
El problema que vamos a resolver
Un proyecto pequeño, shop, con un módulo y dos archivos de test. money.py:
def parse_price(text: str) -> float:
return float(text.replace(",", ""))
def split_bill(total: float, people: int) -> float:
return round(total / people, 2)test_money.py lo comprueba con valores sencillos. test_export.py lee precios desde YAML, lo que requiere el paquete de terceros yaml:
import yaml
from money import parse_price
def test_prices_from_yaml():
data = yaml.safe_load("pen: '15.00'")
assert parse_price(data["pen"]) == 15.0En una máquina donde yaml no está instalado, pytest -q dice:
==================================== ERRORS ====================================
_______________________ ERROR collecting test_export.py ________________________
ImportError while importing test module '/home/you/shop/test_export.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)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_export.py:1: in <module>
import yaml
E ModuleNotFoundError: No module named 'yaml'
=========================== short test summary info ============================
ERROR test_export.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sLee las dos últimas líneas. Interrupted. Los dos buenos tests de test_money.py nunca se ejecutaron. Un archivo que no podía ejecutarse en esta máquina se llevó por delante toda la ejecución.
Esa es la situación real en cualquier proyecto que viva lo suficiente. Algunos tests necesitan una biblioteca opcional. Algunos solo tienen sentido en Linux. Algunos necesitan una clave de API que solo CI tiene. Algunos describen un error que todo el mundo conoce y que nadie ha corregido aún. Algunos tardan un minuto, y no quieres esperar un minuto cada vez que guardas un archivo.
Borrar esos tests es equivocado, y también comentarlos. Lo que quieres es decirle a pytest qué necesita cada test y qué esperas de él, y dejar que pytest decida, ejecución a ejecución, qué hacer con él. La herramienta para eso es el marcador (marker).
By the end of this chapter you can
- Omitir un test siempre, bajo una condición o desde dentro del test, y hacer visible el motivo
- Omitir un archivo entero cuando falta un paquete opcional, con
pytest.importorskip - Marcar un error conocido con
xfail, y afinarlo constrict=Trueyraises= - Leer las letras del informe
s,xyX, y pedir detalles con-rs,-rx,-rA - Inventar tus propios marcadores, registrarlos y cazar erratas con
--strict-markers - Seleccionar tests con
-m "not slow"y-m "slow and db" - Escribir una fixture que ejecuta cada test varias veces, y una fixture que devuelve una función
Requisitos previos: mocking.
Before you write the test
Los marcadores no tratan de qué comprueba un test —los capítulos anteriores ya lo cubrieron—. Tratan de dos preguntas que respondes antes de escribir el test, y que la mayoría de la gente solo responde cuando una ejecución se ha puesto roja por el motivo equivocado.
1. ¿Qué necesita este test para poder ejecutarse siquiera? Un paquete que no es una dependencia dura, un sistema operativo, una versión de Python, una variable de entorno con un secreto, una base de datos, red, mucho tiempo. Cada uno de estos es una precondición. Si falta, el test no tiene nada que decir: no pasó, pero tampoco falló.
2. ¿Qué espero que haga hoy? Normalmente, pasar. Pero si el código tiene un error conocido y estás escribiendo el test que describe el comportamiento correcto, esperas que falle —y que falle de una manera concreta.
Las respuestas determinan el marcador, así que anótalas primero. Para el proyecto shop el plan se ve así:
| Caso | Entrada / situación | Necesita | Esperado | Marcador | |---|---|---|---|---| | precio sencillo | "1,250.50" | nada | 1250.5 | ninguno | | exportación YAML | "pen: '15.00'" | el paquete yaml | 15.0 donde esté instalado | pytest.importorskip("yaml") | | ruta POSIX | "data", "prices.csv" | no Windows | "data/prices.csv" | skipif(sys.platform == "win32") | | tipos de cambio en vivo | la API real | RATES_API_KEY definida | un tipo de cambio, solo donde exista la clave | pytest.skip() dentro del test | | símbolo de moneda | "$1,250" | nada | 1250.0 — hoy ValueError (error #42) | xfail(raises=ValueError, strict=True) | | muchos precios | 1000 cadenas | un segundo o más | suma correcta | slow (personalizado) |
Lee la tabla por columnas. La columna Necesita produce skips. La columna Esperado produce xfails. El tiempo que consumen produce un marcador personalizado que te permite elegir qué ejecutar.
Lo que necesitas tener en marcha antes de que todo esto funcione:
- un entorno virtual con
pytestinstalado, ymoney.pyimportable desde los tests (ejecutado desde la raíz del proyecto, como en capítulos anteriores); - un
pyproject.tomlen la raíz del proyecto, porque ahí es donde se registran los marcadores personalizados; - el error anotado en algún lugar con un número, para que
reason=pueda apuntar a él.
Y qué no marcar:
- No omitas un test porque falla y no sabes por qué. Un skip oculta el fallo de todas las ejecuciones siguientes. O lo corriges, o lo entiendes lo suficiente como para escribir
xfail(raises=...)con un motivo. - No marques
xfailun test intermitente. Un test que a veces pasa mostraráXalgunos días yxotros, y constrict=Truefallará al azar. La intermitencia es un error del test; corrígelo. - No etiquetes todo como
slow. Si en cada ejecución se deselecciona la mayor parte de la suite, la ejecución cotidiana no comprueba nada. Etiqueta solo los pocos tests que de verdad cuestan segundos. - No compruebes que pytest omite correctamente. Estás probando
money.py, no pytest. El marcador es configuración; el informe de la ejecución es prueba suficiente de que funciona.
El resto del capítulo implementa esta tabla, fila por fila.
skip — not this test, and here is why
Primero la forma en que se hace habitualmente antes de que nadie conozca los marcadores —un return temprano—:
import os
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
return
assert False, "would call the real API here". [100%]
1 passed in 0.07sPasó. El test no comprobó absolutamente nada, y el informe dice que tuvo éxito. En un portátil sin la clave, eso es una mentira que se cuenta en cada ejecución, y el punto verde es indistinguible de uno real. La solución no es evitar la condición, sino informar de ella con honestidad —como un skip.
El marcador más simple es un decorador que dice «no ejecutes esto», con un motivo adjunto:
import os
import sys
import pytest
from money import parse_price, split_bill
@pytest.mark.skip(reason="rounding rules not agreed yet")
def test_split_rounding():
assert split_bill(100.0, 3) == 33.34
@pytest.mark.skipif(sys.platform == "win32", reason="uses a POSIX path")
def test_posix_path():
assert os.path.join("data", "prices.csv") == "data/prices.csv"
@pytest.mark.skipif(sys.version_info < (3, 13), reason="needs Python 3.13")
def test_new_feature():
assert parse_price("10") == 10.0
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
assert False, "would call the real API here"s.ss [100%]
1 passed, 3 skipped in 0.09sCuatro tests, cuatro maneras distintas de decidir:
@pytest.mark.skip(reason=...)— siempre omitido. El cuerpo nunca se ejecuta.@pytest.mark.skipif(condición, reason=...)— omitido cuando la condición es verdadera. La condición se evalúa cuando se recopila el archivo. Esta ejecución fue en Linux, así quetest_posix_pathse ejecutó y pasó; la versión de Python era la 3.12, así quetest_new_featurese omitió.pytest.skip("...")llamado dentro del test — la decisión se toma mientras el test se está ejecutando. Úsalo cuando solo te enteras a mitad de camino, por ejemplo tras mirar el entorno o lo que devolvió una fixture.
Cada s en la línea de progreso es un test omitido. Un skip no es un fallo: la ejecución está en verde.
Pero los motivos no aparecen en ninguna parte. Por defecto, pytest solo resume fallos y errores. Pide los skips con -rs:
s.ss [100%]
=========================== short test summary info ============================
SKIPPED [1] test_skips.py:9: rounding rules not agreed yet
SKIPPED [1] test_skips.py:19: needs Python 3.13
SKIPPED [1] test_skips.py:26: RATES_API_KEY is not set
1 passed, 3 skipped in 0.10sPor eso el reason importa. Dentro de tres meses, «skipped» por sí solo no le dice a nadie si el test puede volver a activarse.
importorskip — when a whole file needs a package
Volvamos a test_export.py. Reemplaza import yaml por esto:
import pytest
from money import parse_price
yaml = pytest.importorskip("yaml")
def test_prices_from_yaml():
data = yaml.safe_load("pen: '15.00'")
assert parse_price(data["pen"]) == 15.0.. [100%]
=========================== short test summary info ============================
SKIPPED [1] test_export.py:5: could not import 'yaml': No module named 'yaml'
2 passed, 1 skipped in 0.08spytest.importorskip("yaml") intenta la importación. Si funciona, devuelve el módulo, por eso se asigna a yaml. Si falla, se omite el archivo entero, con el error de importación como motivo. Los otros dos tests ya se ejecutan. Instala yaml y el mismo archivo se ejecuta por completo, sin ningún cambio.
xfail — a bug you know about
Un skip dice «este test no puede ejecutarse aquí». Hay una segunda declaración, distinta: «este test se ejecuta, y se espera que falle, porque el código tiene un error conocido».
parse_price tiene uno. No maneja un símbolo de moneda:
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped")
def test_parse_with_symbol():
assert parse_price("$1,250") == 1250.0
@pytest.mark.xfail(reason="spaces as thousands separators")
def test_parse_with_spaces():
assert parse_price("1 250") == 1250.0pytest -q -rx:
xx [100%]
=========================== short test summary info ============================
XFAIL test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
XFAIL test_xfail.py::test_parse_with_spaces - spaces as thousands separators
2 xfailed in 0.08sA diferencia de un test omitido, un test xfail sí se ejecuta. Falló, como se predijo, así que se muestra con una x minúscula y la ejecución sigue en verde. El test es ahora un registro escrito del error, que vive en la suite, en lugar de una nota en la cabeza de alguien.
¿Por qué no simplemente hacerlo skip? Porque un test omitido nunca se ejecuta, así que nunca puede decirte nada —ni cuando se corrige el error, ni cuando el código cambia de manera que falla de una forma nueva—. Un test xfail se ejecuta cada vez, y su resultado se compara con lo que predijiste. Skip es para «no puede ejecutarse aquí»; xfail es para «se ejecuta, y sé qué va a decir».
Ahora alguien corrige el error #42 y cambia money.py para eliminar el $:
Xx [100%]
=================================== XPASSES ====================================
=========================== short test summary info ============================
XPASS test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
1 xfailed, 1 xpassed in 0.12sUna X mayúscula, XPASS: «se esperaba que fallara, pero pasó». Y la ejecución sigue en verde —el código de salida es 0—. Nadie mira una ejecución en verde, así que el marcador permanece, y el test sigue afirmando un error que ya no existe.
strict=True — make good news loud
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped", strict=True)
def test_parse_with_symbol():
assert parse_price("$1,250") == 1250.0F [100%]
=================================== FAILURES ===================================
____________________________ test_parse_with_symbol ____________________________
[XPASS(strict)] bug #42: currency symbol is not stripped
=========================== short test summary info ============================
FAILED test_xfail.py::test_parse_with_symbol - [XPASS(strict)] bug #42: curre...
1 failed in 0.10sCon strict=True, un pase inesperado es un fallo. Suena al revés, y es exactamente lo que quieres: a la persona que corrigió el error se le indica que elimine el marcador, y el test se convierte en un test ordinario que protege la corrección a partir de entonces. Si quieres esto para cada xfail del proyecto, define xfail_strict = true en la configuración de pytest.
raises= — fail for the right reason
Un xfail sin argumentos acepta cualquier fallo. Eso incluye los que no pretendías:
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42")
def test_loose():
assert parse_prise("$1,250") == 1250.0
@pytest.mark.xfail(raises=ValueError, reason="bug #42")
def test_precise():
assert parse_prise("$1,250") == 1250.0xF [100%]
=================================== FAILURES ===================================
_________________________________ test_precise _________________________________
@pytest.mark.xfail(raises=ValueError, reason="bug #42")
def test_precise():
> assert parse_prise("$1,250") == 1250.0
^^^^^^^^^^^
E NameError: name 'parse_prise' is not defined
test_raises.py:13: NameError
=========================== short test summary info ============================
XFAIL test_raises.py::test_loose - bug #42
1 failed, 1 xfailed in 0.09sAmbos tests tienen la misma errata, parse_prise. test_loose se tragó el NameError y reportó una calmada x —habría permanecido «esperando fallar» para siempre, sin probar nunca parse_price—. test_precise declaró qué fallo espera, el ValueError que lanza float("$1250"), así que cualquier otra excepción se reporta como un fallo real. Corrige la errata y ambos pasan a ser x.
The report letters
La línea de progreso usa un carácter por test, y -r toma las mismas letras para elegir qué entra en el resumen:
| Letra | Significado | Se muestra en el resumen con | |---|---|---| | . | pasó | -rp | | F | falló | -rf (activado por defecto) | | E | error en setup o teardown | -rE (activado por defecto) | | s | omitido (skipped) | -rs | | x | xfailed — falló como se esperaba | -rx | | X | xpassed — pasó inesperadamente | -rX |
Las letras se combinan: -rsx muestra skips y xfails juntos. -rA muestra todos, incluidos los pases:
..xs [100%]
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED test_params.py::test_parse_price[15-15.0]
PASSED test_params.py::test_parse_price[1,250.50-1250.5]
SKIPPED [1] test_params.py:6: space separators not decided
XFAIL test_params.py::test_parse_price[$1,250-1250.0] - bug #42
2 passed, 1 skipped, 1 xfailed in 0.08sYour own markers
skip y xfail cambian lo que pytest hace con un test. Un marcador también puede ser una simple etiqueta, con un nombre que inventas tú, que no cambia nada hasta que la usas para seleccionar tests. Supón que algunos tests son lentos y algunos necesitan una base de datos:
import time
import pytest
from money import parse_price, split_bill
def test_parse_plain():
assert parse_price("1,250.50") == 1250.5
@pytest.mark.slow
def test_parse_many():
time.sleep(1)
assert sum(parse_price("1.5") for _ in range(1000)) == 1500.0
@pytest.mark.slow
@pytest.mark.db
def test_bill_from_database():
time.sleep(1)
assert split_bill(300.0, 3) == 100.0
@pytest.mark.db
def test_bill_lookup():
assert split_bill(90.0, 3) == 30.0.... [100%]
=============================== warnings summary ===============================
test_marks.py:12
/home/you/shop/test_marks.py:12: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
test_marks.py:18
/home/you/shop/test_marks.py:18: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
test_marks.py:19
/home/you/shop/test_marks.py:19: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.db
test_marks.py:25
/home/you/shop/test_marks.py:25: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.db
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
4 passed, 4 warnings in 2.09sLos cuatro pasaron, y pytest se queja de cada uso. pytest.mark.<cualquier cosa> se acepta —el atributo se crea sobre la marcha—, así que pytest no puede distinguir slow de una errata de otro nombre. Te pide que declares los nombres que tienes en mente.
Registering markers
La lista va en pyproject.toml, una cadena por marcador, el nombre antes de los dos puntos y una descripción después:
[tool.pytest.ini_options]
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"db: needs the database",
].... [100%]
4 passed in 2.11sLos avisos desaparecen, y pytest --markers los lista ahora para la próxima persona que se una al proyecto:
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'
@pytest.mark.db: needs the databaseSelecting with -m
Ahora las etiquetas se ganan su sitio. -m toma una expresión sobre nombres de marcadores, con and, or, not y paréntesis:
$ pytest -q -m "not slow"
.. [100%]
2 passed, 2 deselected in 0.07s
$ pytest -q -m "slow and db"
. [100%]
1 passed, 3 deselected in 1.08s
$ pytest -q -m "slow or db"
... [100%]
3 passed, 1 deselected in 2.07sDeselected no es skipped. Un test omitido fue elegido y luego se negó a ejecutar; un test deseleccionado nunca fue elegido. -m "not slow" es la ejecución que haces en cada guardado; la ejecución completa es para CI y antes de hacer push.
--strict-markers — a typo becomes an error
El aviso es fácil de pasar por alto. Aquí hay un test que alguien añadió después, con el marcador mal escrito:
@pytest.mark.slwo
def test_split_large_group():
time.sleep(1)
assert split_bill(1000.0, 8) == 125.0... [100%]
=============================== warnings summary ===============================
test_marks.py:30
/home/you/shop/test_marks.py:30: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slwo
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
3 passed, 2 deselected, 1 warning in 1.07sSe pidió -m "not slow", y la ejecución aun así llevó un segundo: el test mal escrito no es slow, así que se ejecutó. Con --strict-markers, un marcador no registrado detiene la recopilación en su lugar:
==================================== ERRORS ====================================
________________________ ERROR collecting test_marks.py ________________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR test_marks.py - Failed: 'slwo' not found in `markers` configuration option
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sNo quieres escribir ese flag cada vez, así que ponlo en addopts:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"db: needs the database",
]pytestmark — mark a whole file
Cuando cada test de un archivo necesita la base de datos, decorar a cada uno es repetición. Una variable a nivel de módulo llamada pytestmark se aplica a cada test del archivo:
import pytest
from money import split_bill
pytestmark = pytest.mark.db
def test_bill_for_two():
assert split_bill(50.0, 2) == 25.0
def test_bill_for_four():
assert split_bill(80.0, 4) == 20.0$ pytest -q -m db
.. [100%]
2 passed, 4 deselected in 0.07sEl nombre debe ser exactamente pytestmark. También puede ser una lista: pytestmark = [pytest.mark.db, pytest.mark.slow].
Marks on a single case of parametrize
Un marcador en un test parametrizado se aplica a cada caso. Para marcar un caso, envuélvelo en pytest.param(..., marks=...):
import pytest
from money import parse_price
@pytest.mark.parametrize(
"text, expected",
[
("15", 15.0),
("1,250.50", 1250.5),
pytest.param(
"$1,250", 1250.0,
marks=pytest.mark.xfail(raises=ValueError, reason="bug #42"),
),
pytest.param(
"1 250", 1250.0,
marks=pytest.mark.skip(reason="space separators not decided"),
),
],
)
def test_parse_price(text, expected):
assert parse_price(text) == expectedtest_params.py::test_parse_price[15-15.0] PASSED [ 25%]
test_params.py::test_parse_price[1,250.50-1250.5] PASSED [ 50%]
test_params.py::test_parse_price[$1,250-1250.0] XFAIL (bug #42) [ 75%]
test_params.py::test_parse_price[1 250-1250.0] SKIPPED (space separa...) [100%]
=================== 2 passed, 1 skipped, 1 xfailed in 0.11s ====================El error conocido vive junto a los casos que funcionan, en la misma tabla, y el día que se corrija eliminas un envoltorio pytest.param.
Going further: fixtures that vary, and fixtures that build
Dos patrones de fixtures no entraron en los capítulos de fixtures, y los marcadores son un buen momento para ellos, porque ambos multiplican o dan forma a los tests que acabas de aprender a seleccionar.
A parametrized fixture
parametrize varía las entradas de un test. A veces quieres que cada test que usa una fixture se ejecute una vez por valor de esa fixture. Dale a @pytest.fixture una lista params, y lee el valor actual desde request.param:
import pytest
from money import parse_price
@pytest.fixture(params=["1250", "1,250", "1,250.00"], ids=["plain", "comma", "decimals"])
def price_text(request):
return request.param
def test_parses_to_1250(price_text):
assert parse_price(price_text) == 1250.0
def test_is_positive(price_text):
assert parse_price(price_text) > 0test_fixtures.py::test_parses_to_1250[plain] PASSED [ 16%]
test_fixtures.py::test_parses_to_1250[comma] PASSED [ 33%]
test_fixtures.py::test_parses_to_1250[decimals] PASSED [ 50%]
test_fixtures.py::test_is_positive[plain] PASSED [ 66%]
test_fixtures.py::test_is_positive[comma] PASSED [ 83%]
test_fixtures.py::test_is_positive[decimals] PASSED [100%]
============================== 6 passed in 0.08s ===============================Dos tests, tres valores, seis ejecuciones. request es una fixture incorporada que describe el test que se está preparando; request.param existe solo cuando la fixture tiene params. ids nombra cada valor en el informe —sin ello, pytest construye un id a partir del propio valor, lo cual para valores largos o no imprimibles es difícil de leer—. La lista también puede contener entradas pytest.param(...) con marks=, exactamente igual que en parametrize.
Dónde gana su sitio: el mismo conjunto de tests contra varios backends, varios formatos de archivo, varias monedas. Añade un valor a params, y cada test que usa la fixture lo cubre.
A factory fixture
Una fixture no puede recibir argumentos del test; el test solo la nombra. Entonces, ¿qué haces cuando un test necesita dos objetos, construidos de manera distinta? Devuelve una función:
import pytest
from money import parse_price
@pytest.fixture
def make_receipt(tmp_path):
def _make(name, lines):
path = tmp_path / f"{name}.txt"
path.write_text("\n".join(lines))
return path
return _make
def receipt_total(path):
return sum(parse_price(line) for line in path.read_text().splitlines())
def test_two_receipts(make_receipt):
lunch = make_receipt("lunch", ["15", "1,250.50"])
taxi = make_receipt("taxi", ["300"])
assert receipt_total(lunch) + receipt_total(taxi) == 1565.5
def test_empty_receipt(make_receipt):
empty = make_receipt("empty", [])
assert receipt_total(empty) == 0.. [100%]
2 passed in 0.08sLa fixture sigue ejecutándose una vez por test, y sigue recibiendo sus propias fixtures (tmp_path aquí, que además se encarga de borrar los archivos). Lo que entrega es _make, y el test la llama las veces que quiera, con los argumentos que quiera. El nombre make_... es la convención habitual, y le dice al lector de un vistazo que esta fixture es una función que se llama, no un valor ya hecho.
A complete example
Todo junto en un proyecto. pyproject.toml:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"network: talks to an outside service",
]test_checkout.py, usando el mismo money.py —todavía con el error #42—:
import os
import time
import pytest
from money import parse_price, split_bill
# Every test that asks for amount_text runs once per format.
@pytest.fixture(params=["300", "300.00", "1,250"], ids=["whole", "decimals", "comma"])
def amount_text(request):
return request.param
# A factory: the fixture hands back a function the test can call many times.
@pytest.fixture
def make_share():
def _make(text, people):
return split_bill(parse_price(text), people)
return _make
def test_share_is_positive(amount_text, make_share):
assert make_share(amount_text, 3) > 0
@pytest.mark.parametrize(
"text, people, share",
[
("300", 3, 100.0),
("90", 4, 22.5),
pytest.param(
"$300", 3, 100.0,
marks=pytest.mark.xfail(raises=ValueError, strict=True, reason="bug #42"),
),
],
)
def test_share(make_share, text, people, share):
assert make_share(text, people) == share
@pytest.mark.slow
def test_many_group_sizes(make_share):
time.sleep(1) # stands in for real, slow work
assert all(make_share("300", n) > 0 for n in range(1, 1000))
@pytest.mark.network
def test_live_exchange_rate():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
raise AssertionError("would call the real service here")La ejecución cotidiana, con motivos:
$ pytest -q -rsx -m "not slow"
.....xs [100%]
=========================== short test summary info ============================
SKIPPED [1] test_checkout.py:52: RATES_API_KEY is not set
XFAIL test_checkout.py::test_share[$300-3-100.0] - bug #42
5 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09sSolo el lento, y la ejecución sin red que deja fuera tanto los tests lentos como los de red:
$ pytest -q -m slow
. [100%]
1 passed, 7 deselected in 1.07s
$ pytest -q -m "not slow and not network"
.....x [100%]
5 passed, 2 deselected, 1 xfailed in 0.08sCuenta: una fixture con tres valores da tres ejecuciones de test_share_is_positive, tres casos de test_share, un test lento y uno de red —ocho en total, que es lo que suman 1 passed, 7 deselected—.
Y cada resultado dice la verdad. El test de red no finge pasar sin una clave; dice que fue omitido y por qué. El error #42 está en la suite, y porque es strict con raises=ValueError, el día que se corrija la ejecución se pondrá roja y pedirá que se elimine el marcador.
When it breaks
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? El marcador no está registrado. Añádelo a markers en pyproject.toml. Si el nombre es de verdad una errata, corrígelo —y añade --strict-markers a addopts para que el siguiente sea un error en lugar de un aviso.
`'slwo' not found in markers configuration option` Esto es --strict-markers haciendo su trabajo: el archivo usa un marcador que no está en la lista. Corrige la ortografía, o registra el nuevo nombre.
8 deselected y no tests ran, exit code 5 -m no coincidió con nada. Un nombre mal escrito en una expresión -m no es un error, ni siquiera con --strict-markers —-m sloww simplemente no selecciona ningún test—. Comprueba el nombre con pytest --markers.
ERROR: Wrong expression passed to '-m': not slow and: at column 13: expected not OR left parenthesis OR identifier; got end of input La expresión -m está incompleta. Comilla toda la expresión, y asegúrate de que cada and/or tenga algo a ambos lados.
Error evaluating 'skipif': you need to specify reason=STRING when using booleans as conditions. skipif(sys.platform == "linux") sin reason=. Una condición booleana no dice nada sobre el motivo, así que pytest insiste en un motivo.
`Using pytest.skip outside of a test will skip the entire module. If that's your intention, pass allow_module_level=True.` pytest.skip("...") se llamó en el nivel superior de un archivo. Para un paquete que falta usa pytest.importorskip; si no, escribe pytest.skip("...", allow_module_level=True), o usa un skipif en pytestmark.
[XPASS(strict)] bug #42 Buenas noticias, reportadas como un fallo: el error está corregido. Elimina el marcador xfail y conserva el test.
Un test xfail que sigue siendo x incluso después de corregir el error Está fallando por otro motivo —a menudo una errata en el propio test—. Añade raises= con la excepción que realmente esperas, y cualquier otra cosa se mostrará como un fallo real.
AttributeError: 'SubRequest' object has no attribute 'param' La fixture lee request.param, pero no tiene una lista params=, y no fue parametrizada de ninguna otra manera. Añade params=[...] a @pytest.fixture.
fixture 'name' not found, apuntando a una fixture Una fixture fue escrita como def make_user(name):, esperando que el test pasara name. Los argumentos de una fixture son otras fixtures, así que pytest fue a buscar una llamada name. Conviértela en una factory: una función interna que recibe name, devuelta por la fixture.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
¿Cuál es la línea de progreso y la última línea de pytest -q? (Recuerda que round(2.675, 2) da 2.67.)
import pytest
@pytest.mark.skip(reason="not ready")
def test_export():
assert False
def test_total():
assert 2 + 2 == 4
@pytest.mark.xfail(reason="bug #7")
def test_rounding():
assert round(2.675, 2) == 2.68- A..x 2 passed, 1 xfailed
- Bs.X 1 passed, 1 skipped, 1 xpassed
- Cs.F 1 passed, 1 skipped, 1 failed
- Ds.x 1 passed, 1 skipped, 1 xfailed
slow está registrado en pyproject.toml, y --strict-markers está desactivado. Ejecutas pytest -m "not slow", y la ejecución aun así lleva cinco segundos. ¿Por qué?
import time
import pytest
@pytest.mark.slwo
def test_big_report():
time.sleep(5)
assert True- A`-m "not slow"` ejecuta solo los tests lentos
- BEl marcador está mal escrito como `slwo`, así que el test no es `slow` y se selecciona; pytest solo avisa
- C`time.sleep` hace que pytest ignore los marcadores
- D`-m` solo funciona con marcadores integrados como `skip`
El error #12 ya se corrigió de hecho, así que el assert es verdadero. ¿Qué reporta pytest -q?
import pytest
@pytest.mark.xfail(strict=True, reason="bug #12")
def test_upper():
assert "taka".upper() == "TAKA"- A`X` — 1 xpassed, código de salida 0
- B`F` — FAILED con `[XPASS(strict)]`, código de salida 1
- C`s` — el test se omite porque está marcado
- D`x` — 1 xfailed, código de salida 0
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
Toma un conjunto de tests que ya tengas —de un capítulo anterior o de tu propio trabajo— y dale un conjunto honesto de marcadores:
- Registra dos marcadores tuyos en
pyproject.toml(por ejemploslowynetwork), y activa--strict-markersenaddopts. - Marca al menos un test con cada uno. Usa
time.sleep(1)para fingir uno lento si no tienes ninguno. - Añade un test que necesite una variable de entorno, y ómítelo desde dentro con
pytest.skipcuando la variable falte. - Encuentra, o inventa, un error en tu código. Escribe el test que debería pasar, y márcalo
xfailconstrict=Truey unraises=preciso. - Añade una fixture con
paramseids, usada por al menos dos tests.
Luego ejecuta, y guarda la salida de cada uno:
pytest -q -rsx -m "not slow"pytest -q -m slowpytest -q -rA
Por último, dos experimentos. Escribe mal uno de tus marcadores y ejecuta una vez sin y otra vez con --strict-markers. Después corrige el error del paso 4 y ejecuta de nuevo. Lee lo que pytest dice en cada caso —esos dos mensajes son los que mantienen honesta una suite con marcadores, y es más fácil reconocerlos la segunda vez si los has provocado tú mismo alguna vez—.
Solución
Una solución, usando el money.py del proyecto shop. El error del paso 4 es uno nuevo, el error #51: una celda vacía en una hoja de cálculo llega como "", y debería contar como 0.0 —pero float("") lanza ValueError—.
pyproject.toml:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"network: needs a real outside service and its API key",
]test_solution.py:
import os
import time
import pytest
from money import parse_price, split_bill
# Step 5: one fixture, three formats, used by two tests.
@pytest.fixture(params=["90", "90.00", "1,090"], ids=["whole", "decimals", "comma"])
def amount_text(request):
return request.param
def test_parse_is_positive(amount_text):
assert parse_price(amount_text) > 0
def test_split_for_one_is_whole_amount(amount_text):
assert split_bill(parse_price(amount_text), 1) == parse_price(amount_text)
# Step 2: a slow test, labelled so it can be left out.
@pytest.mark.slow
def test_many_prices():
time.sleep(1) # stands in for real, slow work
assert sum(parse_price("2.5") for _ in range(1000)) == 2500.0
# Step 3: decided inside the test, because only the environment knows.
@pytest.mark.network
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
raise AssertionError("would call the real service here")
# Step 4: bug #51 - an empty cell should count as 0, today it raises ValueError.
@pytest.mark.xfail(raises=ValueError, strict=True, reason="bug #51: empty cell")
def test_empty_cell_is_zero():
assert parse_price("") == 0.0Las tres ejecuciones:
$ pytest -q -rsx -m "not slow"
......sx [100%]
=========================== short test summary info ============================
SKIPPED [1] test_solution.py:34: RATES_API_KEY is not set
XFAIL test_solution.py::test_empty_cell_is_zero - bug #51: empty cell
6 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09s
$ pytest -q -m slow
. [100%]
1 passed, 8 deselected in 1.07s
$ pytest -q -rA
.......sx [100%]
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED test_solution.py::test_parse_is_positive[whole]
PASSED test_solution.py::test_parse_is_positive[decimals]
PASSED test_solution.py::test_parse_is_positive[comma]
PASSED test_solution.py::test_split_for_one_is_whole_amount[whole]
PASSED test_solution.py::test_split_for_one_is_whole_amount[decimals]
PASSED test_solution.py::test_split_for_one_is_whole_amount[comma]
PASSED test_solution.py::test_many_prices
SKIPPED [1] test_solution.py:34: RATES_API_KEY is not set
XFAIL test_solution.py::test_empty_cell_is_zero - bug #51: empty cell
7 passed, 1 skipped, 1 xfailed in 1.08sEl primer experimento: @pytest.mark.slow escrito mal como @pytest.mark.slwo. Sin --strict-markers (aquí desactivado con -o addopts=""), la ejecución aun así lleva un segundo, porque el test «lento» no fue deseleccionado:
$ pytest -q -m "not slow" -o addopts=""
.......sx [100%]
=============================== warnings summary ===============================
test_solution.py:24
/home/you/shop/test_solution.py:24: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slwo
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
7 passed, 1 skipped, 1 xfailed, 1 warning in 1.09sCon él, la ejecución se niega a empezar:
$ pytest -q -m "not slow"
==================================== ERRORS ====================================
______________________ ERROR collecting test_solution.py _______________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR test_solution.py - Failed: 'slwo' not found in `markers` configuration ...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sEl segundo experimento: error #51 corregido en money.py:
def parse_price(text: str) -> float:
if not text.strip():
return 0.0
return float(text.replace(",", ""))
def split_bill(total: float, people: int) -> float:
return round(total / people, 2)$ pytest -q -m "not slow"
......sF [100%]
=================================== FAILURES ===================================
___________________________ test_empty_cell_is_zero ____________________________
[XPASS(strict)] bug #51: empty cell
=========================== short test summary info ============================
FAILED test_solution.py::test_empty_cell_is_zero - [XPASS(strict)] bug #51: e...
1 failed, 6 passed, 1 skipped, 1 deselected in 0.08sElimina la línea xfail y el test pasa como un test ordinario a partir de entonces.
El porqué de cada decisión:
--strict-markersenaddopts, no en la línea de comandos. Una red de seguridad que tienes que recordar activar no es una red de seguridad. En la configuración se aplica a todo el mundo, CI incluido.slowcomo etiqueta, no como skip. El test lento es un buen test; debería ejecutarse, solo que no en cada guardado. Una etiqueta te permite elegir; un skip lo eliminaría de cada ejecución.pytest.skip()dentro del test para la clave de API, noskipif. Ambos funcionarían aquí, pero la comprobación pertenece al código que necesita la clave. Cuando un test solo se entera a mitad de camino de que no puede continuar, la forma interna es la única que funciona.raises=ValueErrorystrict=Truejuntos.raises=se asegura de que el test falle a causa del error #51 y no por una errata;strict=Truese asegura de que se note el día en que el error se corrija. Cada uno cierra una manera distinta de que el marcador empiece a mentir.- Una fixture parametrizada, no
parametrize, para los formatos. Dos tests comparten las mismas tres entradas. Con una fixture la lista vive en un solo sitio; añadir un cuarto formato lo añade a ambos tests a la vez. - Ids
whole,decimals,comma. En el informe,[comma]dice de qué va el caso.[1,090]también funcionaría aquí, pero los ids siguen funcionando cuando los valores son largos o no imprimibles.
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