Descubrimiento y ejecución de tests: qué ejecuta pytest y cómo elegir
Qué archivos, funciones y clases considera pytest como tests, cómo importar tu código desde una estructura con src/ y tests/, node IDs y cómo ejecutar exactamente lo que necesitas con -k, -x, --lf, --ff y --co.
¿Cómo quieres aprender?
El problema que vamos a resolver
Hasta ahora, cada test ha vivido en un solo archivo, junto al código que prueba, y ejecutar pytest a secas ha sido suficiente. Los proyectos reales no se quedan tan pequeños. El código pasa a un paquete, los tests se trasladan a su propia carpeta y la siguiente ejecución tiene este aspecto:
$ pytest -q
==================================== ERRORS ====================================
_____________________ ERROR collecting tests/test_cart.py ______________________
ImportError while importing test module '/home/you/shop/tests/test_cart.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_cart.py:1: in <module>
from shop.cart import Cart
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_cart.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sNo se ejecutó ni un solo test. El código está bien; simplemente pytest no pudo encontrarlo.
Una vez solucionado esto, surge un segundo problema: ahora hay decenas de tests, uno de ellos falla y cada nueva ejecución vuelve a ejecutarlos todos. Quieres ejecutar únicamente ese —o solo los que fallaron la última vez—.
Ambos problemas provienen del mismo lugar. Antes de que pytest ejecute nada, realiza la recolección (collect): recorre las carpetas, importa archivos y construye una lista de tests. Este capítulo trata sobre cómo se crea esa lista y cómo elegir elementos de ella.
Al terminar este capítulo podrás
- Indicar las reglas que utiliza
pytestpara decidir qué es un test - Estructurar un proyecto con
src/ytests/, y hacer que los tests puedan importar el código - Agrupar tests en clases y leer un node ID como
tests/test_cart.py::TestCart::test_total_adds_tax - Ejecutar un archivo, una clase, un test o una selección de ellos mediante
-k - Utilizar
-x,--maxfail,--lf,--ff,--coy--durationsen el trabajo diario - Leer el código de salida de
pytesty explicar qué significa
Requisitos previos: assert y cómo leer informes de fallos.
Antes de escribir el test
En este capítulo, la pregunta antes de la primera línea de código de test no es solo qué probar, sino dónde vivirá el test y cómo lo encontrará `pytest`. Un test que nunca se recolecta es peor que no tener ningún test: parece una protección pero no lo es.
1. Conoce la promesa. El código que probaremos es un pequeño paquete de tienda:
apply_discount(price, percent)devuelve el precio reducido en unpercent, redondeado a 2 decimales. Un porcentaje fuera de 0–100 lanzaValueError.add_tax(price, rate=0.15)devuelve el precio con impuestos añadidos, redondeado a 2 decimales.Cartrecopila artículos(name, price, quantity);subtotal()multiplica por la cantidad;total()es el subtotal con impuestos.
2. Ten el entorno listo. Un entorno virtual con pytest instalado; el código estructurado en src/shop/; y —la parte de la que trata este capítulo— una forma para que los tests puedan hacer import shop. Si falta esta última parte, nada más importa.
3. Planifica los casos y decide dónde vivirá cada uno. Un archivo de test por módulo (tests/test_pricing.py refleja a src/shop/pricing.py), y los tests del carrito agrupados en una clase. Escribir el node ID en el plan te obliga a decidir eso por adelantado:
| Caso | Entrada | Esperado | Node ID | |---|---|---|---| | Descuento normal | apply_discount(200.0, 10) | 180.0 | tests/test_pricing.py::test_discount_ten_percent | | Límite: sin descuento | apply_discount(200.0, 0) | 200.0 | tests/test_pricing.py::test_discount_zero | | Impuesto por defecto | add_tax(100.0) | 115.0 | tests/test_pricing.py::test_add_tax_default_rate | | Caso extremo: carrito vacío | Cart().subtotal() | 0 | tests/test_cart.py::TestCart::test_empty_cart_subtotal | | La cantidad cuenta | 3 bolígrafos a 15.0 | 45.0 | tests/test_cart.py::TestCart::test_subtotal_counts_quantity | | El total incluye impuestos | 1 bolso a 100.0 | 115.0 | tests/test_cart.py::TestCart::test_total_adds_tax | | Porcentaje no válido | apply_discount(200.0, 150) | ValueError | más adelante —probar excepciones tiene su propio capítulo— |
4. Decide qué no probar. No pruebes round() ni list.append: el comportamiento propio de Python no es tu promesa. No pruebes que Cart.items sea una lista de tuplas; ese es un detalle interno que podrías cambiar mañana. Prueba lo que ve quien llama al código: subtotal() y total().
Ahora el capítulo construye exactamente ese plan, y cada problema en el camino es un problema de descubrimiento.
Las reglas del descubrimiento
pytest no ejecuta cualquier cosa que encuentra. Sigue cuatro reglas, y todo lo que quede fuera de ellas se ignora sin decir una palabra:
- Archivos llamados
test_*.pyo*_test.py - Funciones en esos archivos cuyos nombres empiezan por
test - Clases cuyos nombres empiezan por
Test—y que no tienen método__init__— - Métodos dentro de una clase de ese tipo cuyos nombres empiezan por
test
¿Por qué tener reglas en absoluto, en lugar de «ejecutar cada función»? Porque tu proyecto está lleno de funciones que no son tests —funciones auxiliares, el propio código bajo prueba— y pytest necesita una forma de distinguirlas sin que tengas que registrar nada.
La forma más rápida de convencerse de las reglas es romperlas a propósito. test_rules.py:
def test_found():
assert True
def check_not_found():
assert False
class TestGroup:
def test_method_found(self):
assert True
def helper(self):
assert False
class CartTests:
def test_ignored(self):
assert False
class TestWithInit:
def __init__(self):
self.value = 1
def test_never_runs(self):
assert self.value == 1Junto a él, rules_test.py contiene un test_suffix_style que pasa, y helpers.py contiene un test_in_helpers que hace assert False. Ahora pytest -v:
collecting ... collected 3 items
rules_test.py::test_suffix_style PASSED [ 33%]
test_rules.py::test_found PASSED [ 66%]
test_rules.py::TestGroup::test_method_found PASSED [100%]
=============================== warnings summary ===============================
test_rules.py:22
/home/you/disc/test_rules.py:22: PytestCollectionWarning: cannot collect test class 'TestWithInit' because it has a __init__ constructor (from: test_rules.py)
class TestWithInit:
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.01s =========================Hay cinco líneas assert False en esa carpeta y la ejecución sale en verde. Qué se omitió y por qué:
check_not_found—el nombre no empieza portest—.TestGroup.helper—está dentro de una clase de test, pero su propio nombre no empieza portest. Los métodos auxiliares están permitidos; simplemente no son tests—.CartTests—el nombre de la clase tiene que empezar porTest—.TestWithInit—pytestdebe crear la clase él mismo, sin argumentos, una vez por cada test. No se puede confiar en que una clase con su propio__init__permita eso, por lo que se omite —con una advertencia, el único aviso en toda la ejecución—.helpers.py—el nombre del archivo no coincide con ninguno de los dos patrones, por lo que ni siquiera se llega a importar nunca—.
La parte silenciosa es la parte peligrosa. Si escribesdef tset_total():por error, la suite se mantendrá en verde para siempre. Cuando un nuevo test «pasa» de forma sospechosamente fácil, comprueba si llegó a recolectarse en absoluto;--co, que veremos más abajo, es la forma de hacerlo.
Una estructura real: src/ y tests/
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── cart.py
│ └── pricing.py
└── tests/
├── test_cart.py
└── test_pricing.pyEl código vive en src/shop/; los tests viven en tests/ y nunca se distribuyen con el paquete. Esta es la estructura src (src layout). Su propósito es que el código no se pueda importar «por accidente» solo porque casualmente te encuentras en la carpeta del proyecto —de modo que los tests lo importan de la misma manera en que lo haría un usuario real—.
src/shop/pricing.py:
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError(f"percent must be between 0 and 100: {percent}")
return round(price * (100 - percent) / 100, 2)
def add_tax(price, rate=0.15):
return round(price * (1 + rate), 2)src/shop/cart.py:
from shop.pricing import add_tax
class Cart:
def __init__(self):
self.items = []
def add(self, name, price, quantity=1):
self.items.append((name, price, quantity))
def subtotal(self):
return sum(price * quantity for _, price, quantity in self.items)
def total(self):
return add_tax(self.subtotal())tests/test_pricing.py —las primeras tres filas del plan—:
from shop.pricing import add_tax, apply_discount
def test_discount_ten_percent():
assert apply_discount(200.0, 10) == 180.0
def test_discount_zero():
assert apply_discount(200.0, 0) == 200.0
def test_add_tax_default_rate():
assert add_tax(100.0) == 115.0Ese from shop.pricing import ... es exactamente lo que produjo el ModuleNotFoundError al inicio del capítulo.
Por qué no se encuentra shop
import shop tiene éxito únicamente si alguna carpeta en sys.path contiene una carpeta shop. El paquete vive en src/, y src/ no está en sys.path. pytest añade la carpeta de cada archivo de test (aquí tests/) para que un test pueda importar sus elementos vecinos —pero nada añade src/—.
La forma incorrecta es modificar sys.path dentro del archivo de test:
import sys
sys.path.insert(0, "src")
from shop.cart import Cart
def test_empty_cart_subtotal():
assert Cart().subtotal() == 0Desde la carpeta del proyecto, el test pasa. Luego alguien lo ejecuta desde dentro de tests/:
$ cd tests
$ pytest -q
==================================== ERRORS ====================================
________________________ ERROR collecting test_cart.py _________________________
ImportError while importing test module '/home/you/shop/tests/test_cart.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_cart.py:5: in <module>
from shop.cart import Cart
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_cart.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s"src" es una ruta relativa al lugar dondequiera que te encuentres, por lo que el test ahora depende de la carpeta en la que escribas el comando. Y cada archivo de test necesitaría las mismas tres líneas. Existen dos formas correctas.
Forma correcta 1: decirle a pytest dónde está el código. En pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]pythonpath añade src/ a sys.path antes de que se importe ningún test —resuelto desde la raíz del proyecto, no desde donde te encuentres—. testpaths indica dónde buscar cuando escribes pytest a secas. Ahora:
$ 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
testpaths: tests
collected 6 items
tests/test_cart.py ... [ 50%]
tests/test_pricing.py ... [100%]
============================== 6 passed in 0.01s ===============================configfile: pyproject.toml en el encabezado es la prueba de que pytest leyó tu configuración. Los archivos de configuración tienen un capítulo propio más adelante; estas dos líneas son todo lo que necesitas ahora.
Forma correcta 2: instalar el paquete. Si pyproject.toml describe un paquete real (una tabla [build-system] y una tabla [project]), ejecuta pip install -e . una vez. Eso instala el paquete en modo editable: import shop ahora funciona en todas partes —tests, REPL, scripts— y las modificaciones en src/ se reconocen sin tener que reinstalar. Después de eso, pytest -q da los mismos 6 passed sin necesidad de ningún pythonpath. La mayoría de los proyectos empaquetados hacen esto; pythonpath es la opción más rápida cuando el proyecto no es un paquete.
Agrupar tests en clases
tests/test_cart.py coloca las filas del carrito del plan en una sola clase:
from shop.cart import Cart
class TestCart:
def test_empty_cart_subtotal(self):
assert Cart().subtotal() == 0
def test_subtotal_counts_quantity(self):
cart = Cart()
cart.add("pen", 15.0, quantity=3)
assert cart.subtotal() == 45.0
def test_total_adds_tax(self):
cart = Cart()
cart.add("bag", 100.0)
assert cart.total() == 115.0$ pytest -q tests/test_cart.py
... [100%]
3 passed in 0.01sNo hay ninguna clase base de la que heredar: TestCart es una clase ordinaria, y self es simplemente el primer parámetro obligatorio. ¿Para qué molestarse? Porque la clase da a los tests relacionados un único nombre, y ese nombre se convierte en algo que puedes seleccionar: ejecuta «todos los tests del carrito» con un solo argumento.
Para lo que no sirve la clase es para compartir estado. pytest crea una instancia nueva para cada método de test, por lo que un valor guardado en self en un test desaparece en el siguiente. Eso es deliberado: los tests que dependen de los restos de otros pasan o fallan según el orden en que se ejecutan. Cada test anterior construye su propio Cart(). Las fixtures, en un capítulo posterior, son la herramienta adecuada para la configuración compartida.
Node IDs: la dirección de un test
Pídele a pytest que liste lo que ha recolectado sin ejecutar nada —--collect-only, o --co para abreviar—:
$ pytest --co -q
tests/test_cart.py::TestCart::test_empty_cart_subtotal
tests/test_cart.py::TestCart::test_subtotal_counts_quantity
tests/test_cart.py::TestCart::test_total_adds_tax
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
6 tests collected in 0.01sCompáralo con la última columna del plan: cada fila está ahí, en la dirección que elegiste. Cada línea es un node ID —la ruta del archivo, luego ::, después la clase si la hay, y a continuación :: y el nombre del test—. Es la misma cadena que aparece después de FAILED en un resumen de fallos, por lo que puedes copiarla de un fallo y pegarla directamente de vuelta en la línea de comandos.
Elegir qué ejecutar
Por ruta o node ID
La forma incorrecta de ejecutar un solo test es comentar los demás, o renombrarlos para que pytest los omita —y luego olvidar deshacer el cambio—. La forma correcta es darle a pytest una dirección:
$ pytest -q tests/test_pricing.py
... [100%]
3 passed in 0.01s
$ pytest -q tests/test_cart.py::TestCart
... [100%]
3 passed in 0.01s
$ pytest -q tests/test_cart.py::TestCart::test_total_adds_tax
. [100%]
1 passed in 0.01sUna carpeta, un archivo, una clase, un único test: cada nivel del node ID reduce la selección, y el código no se toca nunca.
Por nombre: -k
-k toma una expresión y conserva los tests cuyos nombres coinciden con ella:
$ pytest -q -k discount
.. [100%]
2 passed, 4 deselected in 0.01sLa coincidencia es una búsqueda de subcadenas que no distingue entre mayúsculas y minúsculas, y las palabras se combinan con and, or, not y paréntesis. Pon la expresión entre comillas para que la shell la pase como un solo argumento:
$ pytest -q -k "discount or tax"
.... [100%]
4 passed, 2 deselected in 0.01s
$ pytest -q -k "TestCart and not tax"
.. [100%]
2 passed, 4 deselected in 0.01sPor esto vale la pena elegir los nombres de los tests con cuidado: test_discount_zero se puede encontrar mediante discount, mediante zero, o mediante ambos. Un nombre como test_2 no se puede encontrar con nada.
Hay un detalle que toma a todos por sorpresa alguna vez. -k compara con el nombre del test y con los nombres situados por encima de él: su clase y su archivo:
$ pytest --co -q -k "not cart"
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
3/6 tests collected (3 deselected) in 0.01sNinguno de los tres tests de TestCart tiene cart en su propio nombre y, aun así, los tres se descartaron: viven en test_cart.py. Cuando una selección con -k te sorprenda, añade --co y mira antes de ejecutar.
Seleccionar mediante una etiqueta que tú mismo asignes, en lugar de por nombre, es la función de los markers y de -m, en el capítulo doce.
Cuando falla un test
Rompe el código a propósito: cambia la rate por defecto en add_tax de 0.15 a 0.18. Ahora dos tests fallan. (--tb=no oculta los informes de fallos para que la estructura de la ejecución sea visible; leíste esos informes en el capítulo anterior).
$ pytest -q --tb=no
..F..F [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 4 passed in 0.01s-x se detiene en el primer fallo. Cuando una función estropeada hace que diez tests fallen, el primer informe es el que vale la pena leer; los otros nueve son solo ecos:
$ pytest -q -x --tb=no
..F
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 2 passed in 0.01s--maxfail=N se detiene tras N fallos. -x equivale exactamente a --maxfail=1.
--lf (last failed) vuelve a ejecutar solo lo que falló la última vez. pytest recuerda los fallos en una carpeta .pytest_cache dentro del proyecto:
$ pytest -q --lf --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 2 deselected in 0.01sSolo se ejecutaron los dos fallos. Restaura la tasa original y --lf vuelve a ejecutar esos dos tests y pasan. Ejecuta --lf una vez más sin que quede nada fallando y pytest muestra run-last-failure: no previously failed tests, not deselecting items. y lo ejecuta todo.
--ff (failed first) ejecuta los fallos primero y luego todo lo demás. La respuesta rápida aparece arriba de todo; la comprobación completa de seguridad, después:
$ pytest -q --ff --tb=no
FF.... [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 4 passed in 0.01sFF.... —los dos fallos se ejecutaron primero, antes de los cuatro que pasaron—.
Un buen bucle mientras corriges un error: pytest --lf -x hasta que esté en verde, y luego pytest a secas una vez. ¿Por qué este último paso? Porque --lf solo vuelve a ejecutar lo que estaba en rojo; no puede decirte si tu corrección rompió algo que antes estaba en verde.
Salida de tus tests: captura y -s
Un print dentro de un test parece no hacer nada. (--tb=short a continuación solo hace que el informe de fallos sea más corto). pytest captura todo lo que un test escribe en pantalla y lo muestra únicamente si ese test falla:
def test_quiet():
print("subtotal is", 45.0)
assert 45.0 == 45.0
def test_loud():
print("subtotal is", 40.0)
assert 40.0 == 45.0$ pytest -q --tb=short
.F [100%]
=================================== FAILURES ===================================
__________________________________ test_loud ___________________________________
test_print.py:8: in test_loud
assert 40.0 == 45.0
E assert 40.0 == 45.0
----------------------------- Captured stdout call -----------------------------
subtotal is 40.0
=========================== short test summary info ============================
FAILED test_print.py::test_loud - assert 40.0 == 45.0
1 failed, 1 passed in 0.01sLa salida del test que pasó desapareció; la salida del test que falló se adjunta a su informe bajo Captured stdout call. Con cientos de tests, eso es exactamente lo que deseas: silencio cuando todo va bien, evidencias cuando no. Para ver todo a medida que ocurre, -s desactiva la captura:
$ pytest -q -s --tb=no
subtotal is 45.0
.subtotal is 40.0
F
=========================== short test summary info ============================
FAILED test_print.py::test_loud - assert 40.0 == 45.0
1 failed, 1 passed in 0.01sCódigos de salida
Cuando pytest termina, entrega un número a la shell. Los sistemas de CI deciden «verde» o «rojo» únicamente a partir de ese número:
| Código | Significado | |---|---| | 0 | Todos los tests recolectados pasaron | | 1 | Los tests se ejecutaron y al menos uno falló | | 2 | La ejecución fue interrumpida —un error durante la recolección o Ctrl+C— | | 3 | Un error interno dentro de pytest o de un plugin | | 4 | Un error de uso —una opción desconocida, o una ruta o node ID que no existe— | | 5 | No se recolectó ningún test |
El 5 merece atención. Un -k que no coincide con nada, o una carpeta sin archivos nombrados correctamente, no es un aprobado —y el sistema de CI lo trata como un fallo, lo cual es totalmente acertado—:
$ pytest -q -k refund
6 deselected in 0.01s
$ echo $?
5El ModuleNotFoundError del principio del capítulo terminaba con Interrupted: 1 error during collection: código de salida 2. Un error de recolección no es un test que falla; pytest ni siquiera llegó al punto de ejecutar tests.
Un ejemplo completo
El plan está implementado y en verde. Ahora un test lento se une a tests/test_cart.py, en su propia clase:
from shop.cart import Cart
class TestBigCart:
def test_many_items(self):
cart = Cart()
for _ in range(300_000):
cart.add("pen", 1.0)
assert cart.subtotal() == 300_000.0El archivo todavía conserva TestCart encima de esta clase. Primero comprueba qué ve pytest, antes de ejecutar nada:
$ pytest --co -q
tests/test_cart.py::TestCart::test_empty_cart_subtotal
tests/test_cart.py::TestCart::test_subtotal_counts_quantity
tests/test_cart.py::TestCart::test_total_adds_tax
tests/test_cart.py::TestBigCart::test_many_items
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
7 tests collected in 0.01sSiete tests, cada uno con una dirección. Ejecútalos todos y pide los tres más lentos:
$ pytest -q --durations=3
....... [100%]
============================= slowest 3 durations ==============================
0.04s call tests/test_cart.py::TestBigCart::test_many_items
(2 durations < 0.005s hidden. Use -vv to show these durations.)
7 passed in 0.12sSolo un test fue lo bastante lento como para figurar en la lista. En un proyecto real, --durations=10 es como encuentras el puñado de tests que hacen que toda la suite se sienta lenta. Mientras trabajas en el carrito, deja fuera el test lento:
$ pytest -q -k "cart and not many"
... [100%]
3 passed, 4 deselected in 0.01sTres cosas hicieron que esto funcionara. pythonpath = ["src"] es lo que permite que from shop.cart import Cart funcione. TestBigCart se recolecta porque su nombre empieza por Test y no tiene __init__. Y -k "cart and not many" conservó exactamente los tests de TestCart: cart coincidió con todo en test_cart.py a través del nombre del archivo, y not many eliminó el test lento.
Cuando algo falla
ModuleNotFoundError: No module named 'shop' durante la recolección El código bajo prueba no está en sys.path. Con una estructura src/, añade pythonpath = ["src"] bajo [tool.pytest.ini_options], o instala el paquete con pip install -e .. La ejecución termina con Interrupted: 1 error during collection y código de salida 2.
Funciona con python -m pytest pero no con pytest python -m pytest también añade la carpeta actual a sys.path; pytest a secas no lo hace. Así, un módulo en la raíz del proyecto se puede importar de una forma y no de la otra. No dependas de esa diferencia: configura pythonpath o instala el paquete.
PytestCollectionWarning: cannot collect test class 'TestCart' because it has a __init__ constructor Las clases de test no deben definir __init__. Elimínalo y construye lo que cada test necesita dentro del propio test.
no tests ran, código de salida 5 Nada coincidió con las reglas de descubrimiento. Comprueba el nombre del archivo (test_*.py o *_test.py), los nombres de función (test...), los nombres de clase (Test...). Si usaste -k, es posible que no haya coincidido con nada.
ERROR: not found: .../tests/test_cart.py::test_total_adds_tax El node ID es incorrecto. Ese test vive dentro de TestCart, por lo que su dirección es tests/test_cart.py::TestCart::test_total_adds_tax. Copia los node IDs desde pytest --co -q en lugar de escribirlos a mano. Código de salida 4.
ERROR: file or directory not found: tests/test_carts.py Una errata en la ruta. Código de salida 4.
pytest: error: unrecognized arguments: --maxfial=2 Una opción mal escrita; pytest --help lista las opciones reales. Código de salida 4.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Este archivo está guardado como test_rules.py. Cuando ejecutas pytest -q, ¿cuántos tests pasados reporta la última línea?
def test_found():
assert True
def check_total():
assert True
class TestGroup:
def test_method(self):
assert True
def helper(self):
assert True
class CartTests:
def test_ignored(self):
assert True- A2 passed
- B1 passed
- C3 passed
- D4 passed
El código reside en src/shop/pricing.py. pyproject.toml tiene pythonpath = ["shop"] bajo [tool.pytest.ini_options]. Aun así, pytest reporta ModuleNotFoundError: No module named 'shop'. ¿Qué está mal?
# tests/test_pricing.py
from shop.pricing import add_tax
def test_add_tax_default_rate():
assert add_tax(100.0) == 115.0- AEl nombre de la función debe empezar por `Test`
- B`pythonpath` debe apuntar a la carpeta que *contiene* el paquete `shop`: `["src"]`
- CEl nombre del archivo de test debe terminar en `_test.py`
- D`tests/` necesita un `__init__.py` antes de poder importar nada
Dos tests estaban fallando y has corregido el error. ¿Qué comando vuelve a ejecutar solo esos dos tests?
- Apytest --co
- Bpytest -x
- Cpytest --ff
- Dpytest --lf
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
Una biblioteca cobra tarifas por retraso. Construye este proyecto:
library/
├── src/
│ └── library/
│ ├── __init__.py
│ └── fees.py
└── tests/
└── test_fees.pysrc/library/fees.py:
def late_fee(days_late, daily_rate=5):
if days_late < 0:
raise ValueError(f"days_late cannot be negative: {days_late}")
return min(days_late * daily_rate, 100)- Escribe primero un plan de tests: a tiempo, unos días tarde, exactamente en el tope, pasado el tope, una tarifa diaria personalizada.
- Escribe
tests/test_fees.pycon esos casos como métodos de una sola clase,TestLateFee. Ejecutapytestsin unpyproject.toml, lee el error y compruebaecho $?. - Añade
pyproject.tomlpara que los tests puedan importarlibrary. Llega a verde. - Lista los node IDs con
--co -q. Ejecuta únicamente el test de «pasado el tope» por su node ID. - Usa
-kpara ejecutar solo los tests sobre el tope (cap), y luego todo excepto ellos. - Cambia el tope en
fees.pyde100a90. Ejecuta la suite y luego--lf. Restáuralo y ejecuta--lf, y luego todo.
Solución
El plan:
| Caso | Entrada | Esperado | |---|---|---| | A tiempo | late_fee(0) | 0 | | Unos días tarde | late_fee(3) | 15 | | Límite: exactamente en el tope | late_fee(20) | 100 | | Pasado el tope | late_fee(30) | 100 | | Tarifa personalizada | late_fee(3, daily_rate=2) | 6 | | Días negativos | late_fee(-1) | ValueError —más adelante, con pytest.raises— |
tests/test_fees.py:
from library.fees import late_fee
class TestLateFee:
def test_on_time_costs_nothing(self):
assert late_fee(0) == 0
def test_three_days_late(self):
assert late_fee(3) == 15
def test_fee_reaches_cap_exactly(self):
assert late_fee(20) == 100
def test_fee_never_exceeds_cap(self):
assert late_fee(30) == 100
def test_custom_daily_rate(self):
assert late_fee(3, daily_rate=2) == 6Sin configuración, la ejecución se detiene en la recolección con ModuleNotFoundError: No module named 'library', Interrupted: 1 error during collection, y echo $? imprime 2. Luego pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]$ pytest -q
..... [100%]
5 passed in 0.01s
$ pytest --co -q
tests/test_fees.py::TestLateFee::test_on_time_costs_nothing
tests/test_fees.py::TestLateFee::test_three_days_late
tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly
tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap
tests/test_fees.py::TestLateFee::test_custom_daily_rate
5 tests collected in 0.01s
$ pytest -q tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap
. [100%]
1 passed in 0.01s
$ pytest -q -k cap
.. [100%]
2 passed, 3 deselected in 0.01s
$ pytest -q -k "not cap"
... [100%]
3 passed, 2 deselected in 0.01sCon el tope cambiado a 90:
$ pytest -q --tb=no
..FF. [100%]
=========================== short test summary info ============================
FAILED tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly - assert...
FAILED tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap - assert 9...
2 failed, 3 passed in 0.01s
$ pytest -q --lf --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly - assert...
FAILED tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap - assert 9...
2 failed, 3 deselected in 0.01sCon el tope de vuelta en 100, pytest -q --lf da 2 passed, 3 deselected, y un pytest -q completo da 5 passed.
Por qué estas decisiones:
- Dos tests para el tope, no uno.
late_fee(20)se sitúa exactamente en la frontera, el primer día en que la tarifa alcanza el tope;late_fee(30)está bastante más allá. Los errores por desfase de un elemento (off-by-one) habitan en las fronteras, por lo que la frontera recibe su propio test —y el segundo demuestra que el tope sigue manteniéndose más allá de ella—. - La palabra
capestá en los nombres de ambos tests del tope, y solo ahí. Eso es lo que hizo que-k capy-k "not cap"seleccionaran limpiamente: los nombres se eligieron pensando en la selección. - Una sola clase, sin
__init__, sin estado compartido. Cada método llama alate_feepor sí mismo; la clase únicamente proporciona al grupo una sola dirección:tests/test_fees.py::TestLateFee. pythonpathen lugar de una línea consys.pathen el archivo de test, de modo que los tests pasen desde cualquier carpeta en la que los ejecutes.- La ejecución completa después de
--lf.--lfdemuestra que los dos tests en rojo vuelven a estar en verde; solo la ejecución completa demuestra que los otros tres siguen estando en verde.
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