Pruebas con pytest
De `assert` a pytest, cómo leer un informe de fallos, `pytest.raises`, `parametrize`, `tmp_path` y fixtures — y qué forma de código es fácil de probar.
¿Cómo quieres aprender?
El problema que vamos a resolver
Hasta ahora hemos «probado» el código de una sola manera: ejecutarlo, leer la salida y decidir si parecía correcta.
Eso funciona una vez. El problema empieza la segunda vez.
El programa del capítulo 25 tenía TAX_RATE con valor 0.15. Supón que pasa a 0.18. El programa se ejecuta, salen todos los números, no se lanza ningún error — y no tienes forma de saber si algo cambió, a menos que recuerdes la salida anterior.
Y lo que al final habría que recordar, nadie lo recuerda.
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 1) == 17.25
print("ok")okassert es una frase sencilla: «esto debería ser verdad». Cuando lo es, no pasa nada; cuando no lo es, el programa se detiene con un AssertionError.
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 3) == 51.0
print("ok")AssertionErrorEsa es la base. Este capítulo presenta una herramienta construida sobre ella — pytest — que ejecuta cientos de estas afirmaciones y, cuando una falla, dice por qué.
Al terminar este capítulo podrás
- Escribir un archivo de pruebas y ejecutar
pytest - Leer un informe de fallos y encontrar el problema a partir de él
- Probar errores con
pytest.raises - Ejecutar una misma prueba sobre muchos casos con
@pytest.mark.parametrize - Probar código que trabaja con archivos usando
tmp_path - Decir qué código es fácil de probar, y por qué
Lo que necesitas primero: Dataclasses y anotaciones de tipo.
Una primera prueba
Dos reglas, y nada más: el nombre del archivo empieza por test_, y también el nombre de la función de prueba. (pytest también busca archivos que terminan en _test.py, pero en este curso empezamos los nombres con test_.)
pricing.py:
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)test_pricing.py:
from pricing import line_total
def test_one_item():
assert line_total(15.0, 1) == 17.25
def test_three_items():
assert line_total(15.0, 3) == 51.75Luego ejecuta pytest -q:
.. [100%]
2 passed in 0.00sDos puntos, dos pruebas que pasan. pytest encuentra los archivos, encuentra las funciones y las ejecuta — no hay que registrar nada en ningún sitio. El tiempo del final (0.00s) puede ser un poco distinto en tu máquina.
El informe de fallos es lo que importa
Pon un número equivocado a propósito en la segunda prueba de test_pricing.py — 51.0 en lugar de 51.75 — y vuelve a ejecutar pytest -q:
.F [100%]
=================================== FAILURES ===================================
_______________________________ test_three_items _______________________________
def test_three_items():
> assert line_total(15.0, 3) == 51.0
E assert 51.75 == 51.0
E + where 51.75 = line_total(15.0, 3)
test_pricing.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_three_items - assert 51.75 == 51.0
1 failed, 1 passed in 0.01sEl .F de la primera línea significa que la primera prueba pasó y la segunda falló. Un assert normal solo nos habría dado AssertionError — una palabra, sin información. Aquí obtenemos:
- Qué prueba (
test_three_items) y en qué línea (test_pricing.py:9) - El
assertexacto que falló, marcado con> assert 51.75 == 51.0— lo que realmente se produjo, frente a lo que se esperabawhere 51.75 = line_total(15.0, 3)— de dónde salió ese número- El recuento en la última línea:
1 failed, 1 passed
Esto se llama introspección de aserciones, y es la razón principal para usar pytest. Muchas herramientas de prueba necesitan para esto un método aparte, como assertEqual(a, b); aquí basta un == normal.
Los errores también son comportamiento
El capítulo 24 dijo que una función debería raise ante una entrada incorrecta. Ese comportamiento también es una promesa, y por eso también se puede probar:
import pytest
from pricing import line_total
def test_zero_is_rejected():
with pytest.raises(ValueError):
line_total(15.0, 0)
def test_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3).. [100%]
2 passed in 0.01sEl bloque pytest.raises dice «este error debería ocurrir aquí dentro». Cuando no ocurre, la prueba falla. Por ejemplo, si una prueba llamada test_one_is_rejected espera un ValueError de line_total(15.0, 1) — aunque una cantidad de 1 es válida:
F [100%]
=================================== FAILURES ===================================
_____________________________ test_one_is_rejected _____________________________
def test_one_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_pricing.py:7: Failed
=========================== short test summary info ============================
FAILED test_pricing.py::test_one_is_rejected - Failed: DID NOT RAISE ValueError
1 failed in 0.01sEl parámetro match= comprueba que el mensaje contiene ese texto. Vale la pena escribirlo, porque la regla del capítulo 24 — pon el valor problemático en el mensaje — pasa entonces a ser una promesa comprobada por sí misma.
Una prueba, muchos casos
Escribir cuatro funciones para cuatro casos es aburrido, y invita a errores de copiar y pegar.
import pytest
from pricing import line_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(100.0, 2, 230.0),
],
)
def test_line_total(price, quantity, expected):
assert line_total(price, quantity) == expectedCon pytest -v:
============================= test session starts ==============================
collecting ... collected 4 items
test_pricing.py::test_line_total[15.0-1-17.25] PASSED [ 25%]
test_pricing.py::test_line_total[15.0-3-51.75] PASSED [ 50%]
test_pricing.py::test_line_total[0.0-5-0.0] PASSED [ 75%]
test_pricing.py::test_line_total[100.0-2-230.0] PASSED [100%]
============================== 4 passed in 0.01s ===============================Cuatro pruebas separadas a partir de una sola función. Cada nombre lleva sus valores, así que cuando una falla ves de inmediato cuál es. Escrito como un bucle, el primer fallo detendría a las demás, y no diría qué valores tienen la culpa.
Pon 51.0 en lugar de 51.75 en el segundo caso y ejecuta pytest -q; el principio del informe muestra también los valores:
.F.. [100%]
=================================== FAILURES ===================================
_________________________ test_line_total[15.0-3-51.0] _________________________
price = 15.0, quantity = 3, expected = 51.0Cuando hace falta un archivo — tmp_path
Probar código que lee archivos, como el del capítulo 23, necesita un archivo. Dejar uno en el repositorio es mala costumbre: las pruebas quedan enredadas entre sí, y una prueba que cambia el archivo rompe otra.
Supón que reader.py tiene esta función — devuelve las líneas no vacías de un archivo de texto:
from pathlib import Path
def read_names(path: Path) -> list[str]:
"""Return the non-blank lines of a text file, without spaces at the ends."""
text = path.read_text(encoding="utf-8")
return [line.strip() for line in text.splitlines() if line.strip()]pytest puede darle a cada prueba una carpeta vacía propia. test_reader.py:
from reader import read_names
def test_blank_lines_are_skipped(tmp_path):
path = tmp_path / "names.txt"
path.write_text("one\n\ntwo\n", encoding="utf-8")
assert read_names(path) == ["one", "two"]
def test_empty_file_gives_empty_list(tmp_path):
path = tmp_path / "names.txt"
path.write_text("", encoding="utf-8")
assert read_names(path) == [].. [100%]
2 passed in 0.01stmp_path es un pathlib.Path — el Path del capítulo 23, unido con / tal como allí. El nombre del parámetro es lo que le dice a pytest qué proporcionar. Cada prueba recibe una carpeta nueva, así que dos pruebas pueden usar el mismo nombre de archivo sin chocar.
Configuración repetida — fixture
Cuando varias pruebas necesitan la misma entrada, se puede escribir una vez y darle un nombre. Aquí order_total es otra función de pricing.py que suma un pedido completo — el archivo completo está en el ejemplo completo de más abajo:
import pytest
from pricing import order_total
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1)]
def test_total(order):
assert order_total(order) == 1029.25
def test_one_line_removed(order):
assert order_total(order[:1]) == 51.75.. [100%]
2 passed in 0.01sIgual que con tmp_path, pytest empareja por el nombre del parámetro. Y la función se vuelve a ejecutar para cada prueba, así que aunque una prueba cambie la lista, la siguiente sigue recibiendo una nueva — el problema de los datos compartidos del capítulo 21, eliminado por diseño.
La trampa del decimal
def test_addition():
assert 0.1 + 0.2 == 0.3F [100%]
=================================== FAILURES ===================================
________________________________ test_addition _________________________________
def test_addition():
> assert 0.1 + 0.2 == 0.3
E assert (0.1 + 0.2) == 0.3
test_money.py:2: AssertionError
=========================== short test summary info ============================
FAILED test_money.py::test_addition - assert (0.1 + 0.2) == 0.3
1 failed in 0.01sEs el viejo problema del capítulo 5, y las pruebas suelen ser el primer lugar donde da problemas.
import pytest
def test_addition():
assert 0.1 + 0.2 == pytest.approx(0.3). [100%]
1 passed in 0.00spytest.approx significa «basta con que sea casi igual». Úsalo en cualquier prueba que involucre flotantes — salvo que el número ya esté fijado con round(), como hace line_total.
Qué probar
Una regla vale más que todas las demás: una función que recibe valores y devuelve un valor es fácil de probar.
¿Recuerdas las funciones puras del capítulo 21? Este capítulo muestra por qué valen la pena. Probar line_total no necesitó ningún archivo, ninguna entrada, ninguna preparación — solo llamarla y mirar el resultado.
En cambio, una función que imprime, pide datos o escribe un archivo necesita preparación extra antes de poder probarse. Por eso report del capítulo 23 no tomaba ningún archivo: recibía una lista y devolvía una lista.
Que las pruebas sean difíciles de escribir suele no ser culpa de las pruebas, sino de la forma del código.
Qué probar, en tres tipos: lo que pasa normalmente, los casos límite (cero, vacío, uno), y lo que se supone que debe fallar.
Un ejemplo completo
pricing.py:
"""Prices and tax. Pure functions, which is what makes them testable."""
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)
def order_total(lines: list[tuple[str, float, int]]) -> float:
return round(sum(line_total(price, qty) for _, price, qty in lines), 2)test_pricing.py:
"""Tests for pricing. Each one names the behaviour it protects."""
import pytest
from pricing import line_total, order_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(0.01, 1, 0.01),
],
)
def test_line_total_applies_tax(price, quantity, expected):
assert line_total(price, quantity) == expected
@pytest.mark.parametrize("quantity", [0, -1, -100])
def test_quantity_below_one_is_rejected(quantity):
with pytest.raises(ValueError, match="at least 1"):
line_total(15.0, quantity)
def test_error_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3)
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1), ("ink", 120.0, 2)]
def test_order_total_sums_the_lines(order):
assert order_total(order) == 1305.25
def test_empty_order_totals_zero():
assert order_total([]) == 0
def test_one_bad_line_stops_the_order(order):
with pytest.raises(ValueError):
order_total(order + [("clip", 5.0, 0)])........... [100%]
11 passed in 0.01sCuatro cosas que conviene notar.
Once pruebas a partir de seis funciones de prueba. parametrize marca la diferencia, y cada caso se cuenta por separado.
Los nombres describen comportamiento, no código. test_quantity_below_one_is_rejected te dice qué promete el programa; test_line_total_2 no te dice nada. Ese nombre es lo primero que muestra un informe de fallos, así que vale la pena que sea una frase.
El caso 0.01 no se eligió al azar. Es el precio más pequeño posible — un caso límite, y justo donde el comportamiento de round() es más dudoso. Los errores viven en los extremos, no en el medio.
La última prueba contiene una promesa indirecta. order_total no valida nada por su cuenta — depende de line_total. Si algún día alguien pone un try/except en order_total para saltar líneas incorrectas en silencio, esta prueba fallará y preguntará: «¿de verdad querías eso?»
Cuando falla
no tests ran El nombre del archivo no empieza por test_ (ni termina en _test.py), o el nombre de la función no empieza por test_. Hacen falta las dos cosas.
ModuleNotFoundError: No module named 'pricing' Ejecuta pytest desde la carpeta donde están los archivos.
Una prueba pasa y no comprueba nada Falta el assert. Llamar a una función basta para que la prueba pase, mientras no lance un error.
fixture 'order' not found Falta el @pytest.fixture, o el nombre no coincide con el del parámetro.
Una comparación de flotantes falla aunque los números parezcan correctos Usa pytest.approx, o round() dentro de la propia función.
DID NOT RAISE El código dentro del bloque pytest.raises no falló. Puede que el código esté mal, o que la expectativa de la prueba lo esté.
Una prueba pasa sola y falla junto con las demás Las pruebas comparten algo — a menudo una lista o un diccionario a nivel de módulo. Son los datos mutables compartidos de los capítulos 21 y 26. Usa un fixture.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Al ejecutar pytest -q, ¿cuál es la última línea del informe?
# checks.py
def is_even(n):
return n % 2 == 0
# test_checks.py
from checks import is_even
def test_even():
assert is_even(4)
def test_odd():
assert is_even(7)- A2 failed in 0.01s
- B1 failed, 1 passed in 0.01s
- C1 failed in 0.01s
- D2 passed in 0.01s
add(2, 2) no es 5. ¿Qué dice pytest -q?
# test_a.py
def add(a, b):
return a + b
def test_add():
add(2, 2) == 5- AUn `AssertionError`
- B1 failed — como debería
- Cno tests ran
- D1 passed — la prueba pasa
La primera prueba puso un artículo en la cesta. ¿Qué ve la segunda?
# helpers.py
def build():
return []
# test_helpers.py
import pytest
from helpers import build
@pytest.fixture
def basket():
return build()
def test_first(basket):
basket.append("one")
assert len(basket) == 1
def test_second(basket):
assert len(basket) == 0- Afixture 'basket' not found
- B2 failed
- C2 passed — la segunda prueba recibe una lista vacía nueva
- D1 failed, 1 passed — la segunda ve un artículo
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 library.py del capítulo 27 — Book y Shelf — y escribe test_library.py junto a él.
Como mínimo, esto:
- Varios casos de
total_pages()conparametrize, incluida una estantería vacía pytest.raisesconmatch=para comprobar quepages=0se rechazalongest()devolviendoNoneen una estantería vacíaby_authordevolviendo exactamente los libros de ese autor, y una lista vacía para alguien que no tiene libros- Una prueba con
tmp_pathque escriba la estantería en JSON y la vuelva a leer
Luego cinco experimentos:
- Quita el
assertde una prueba y solo llama a la función. ¿La prueba pasa? - Renombra el archivo de pruebas a
check_library.pyy ejecutapytest. ¿Qué pasa? (Si lo llamaslibrary_test.py, sí se encuentra — ¿por qué?) - Quita la comprobación
pages < 1deBook. ¿Qué pruebas fallan, y dice el informe por qué? - Mete un error a propósito en
total_pages()— por ejemplosum(...) + 1. ¿El informe de fallos muestra tanto el número real como el esperado? - Escribe una prueba que afirme
0.1 + 0.2 == 0.3. Luego arréglala conpytest.approx.
El tercer experimento es el que más enseña. Un buen conjunto de pruebas, cuando se rompe, te dice qué promesa se rompió — y eso es lo que hace que un programa siga siendo seguro de cambiar seis meses después.
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