unittest cumple, pero arrastra ceremonia: heredar de TestCase, el self. omnipresente, veinte métodos assert* que memorizar. La comunidad Python convergió hace años en una alternativa que reduce un test a su esencia — una función y un assert — sin perder ninguna potencia: pytest. En esta lección reescribirás la suite de Papyrus en estilo pytest, descubrirás las fixtures (parientes directas de tus generadores de 08-03), probarás ficheros sin tocar los datos reales gracias a tmp_path y parametrizarás los cuatro precios de socio canónicos en tres líneas.
Contenido
- Instalación en el venv y primer test
assertdesnudo con superpoderes: la introspección- La misma suite, reescrita: unittest vs pytest
- Excepciones con
pytest.raisesy floats conpytest.approx - Fixtures: el
setUphecho función (y conyield) tmp_path: probar ficheros sin tocardatos/- Parametrización con
@pytest.mark.parametrize - Ejecutar:
pytest -v,-ky compañía - El siguiente escalón de madurez:
unittest.mock(solo el mapa)
Instalación en el venv y primer test
pytest no está en la biblioteca estándar: se instala con pip dentro del venv del proyecto, exactamente como aprendiste en el módulo 1 (y conviene congelarlo en tus dependencias):
# Con el venv activado, en la raíz del proyecto
pip install pytest
pytest --version # comprobación rápidaUn test pytest es una función normal cuyo nombre empieza por test_, en un fichero test_*.py. Sin clases, sin herencia, sin self:
# tests/test_modelos.py — versión pytest
from papyrus.modelos import Libro
def test_precio_sin_socio_aplica_solo_iva():
libro = Libro("La Odisea", 12.50, 4) # Preparar
precio = libro.precio_final() # Actuar
assert precio == 13.00 # ComprobarEjecuta pytest en la raíz del proyecto y él solo descubre tests/, los ficheros test_*.py y las funciones test_* (también entiende clases TestCase, así que tu suite de 09-02 corre sin cambios bajo pytest — la migración puede ser gradual).
assert desnudo con superpoderes: la introspección
En 09-01 dijimos que el assert a pelo daba mensajes pobres, y en 09-02 que assertEqual existía para mostrarte ambos valores. pytest disuelve el dilema: reescribe los assert de tus tests (introspección de aserciones) para que, al fallar, muestren cada subexpresión evaluada. Si el bug de doble redondeo de Ana volviera:
def test_precio_socio_aplica_descuento_e_iva():
libro = Libro("La Odisea", 12.50, 4)
> assert libro.precio_final(socio=True) == 12.35
E assert 12.36 == 12.35
E + where 12.36 = precio_final(socio=True)
E + where precio_final = Libro(titulo='La Odisea', precio=12.5, stock=4).precio_finalObtienes el valor real (12.36), el esperado (12.35) y hasta el __repr__ del Libro implicado (cortesía de la dataclass de M5) — sin haber escrito ningún mensaje. Por eso en pytest el assert desnudo no es una limitación: es la interfaz.
La misma suite, reescrita: unittest vs pytest
La clase TestVender de la lección anterior, traducida:
# tests/test_almacen.py — versión pytest
import pytest
from papyrus.almacen import buscar_libro, vender
from papyrus.errores import StockInsuficienteError
from papyrus.modelos import Libro
@pytest.fixture
def catalogo():
"""Catálogo canónico fresco para cada test que lo pida."""
return {
"La Odisea": Libro("La Odisea", 12.50, 4),
"Hamlet": Libro("Hamlet", 9.95, 6),
"El Quijote": Libro("El Quijote", 15.90, 8),
"Fausto": Libro("Fausto", 21.00, 10),
}
def test_venta_feliz_devuelve_importe_y_descuenta(catalogo):
importe = vender(catalogo, "Hamlet", 2)
assert importe == pytest.approx(20.70)
assert catalogo["Hamlet"].stock == 4
def test_sin_stock_lanza_error_y_no_muta(catalogo):
with pytest.raises(StockInsuficienteError):
vender(catalogo, "La Odisea", 5)
assert catalogo["La Odisea"].stock == 4
def test_buscar_inexistente_devuelve_none(catalogo):
assert buscar_libro(catalogo, "Rayuela") is NoneLa comparación, cara a cara:
| Aspecto | unittest |
pytest |
|---|---|---|
| Origen | Biblioteca estándar (sin instalar) | pip install pytest en el venv |
| Estructura del test | Clase que hereda de TestCase + método |
Función simple |
| Aserción de igualdad | self.assertEqual(a, b) |
assert a == b (con introspección) |
| Floats | self.assertAlmostEqual(a, b, places=2) |
assert a == pytest.approx(b) |
| Excepciones | with self.assertRaises(Exc): |
with pytest.raises(Exc, match="..."): |
| Escenario por test | setUp/tearDown (métodos) |
Fixtures (funciones, inyectadas por nombre) |
| Parametrizar | Bucle + subTest |
@pytest.mark.parametrize |
| Ficheros temporales | Móntatelo tú (tempfile) |
Fixture tmp_path incluida |
| Ejecutar | python -m unittest discover tests |
pytest |
| Ecosistema | Estable, mínimo | Enorme (plugins: cobertura, paralelismo...) |
¿Por qué la comunidad prefiere pytest? Menos ruido por test (lo importante ocupa todo el espacio), mensajes de fallo muy superiores, fixtures componibles y un ecosistema de plugins gigante. unittest sigue siendo valioso — sin dependencias, ubicuo en código corporativo antiguo — y todo lo aprendido en 09-02 (AAA, aislamiento, nombres descriptivos) se traslada intacto: solo cambia la sintaxis.
Excepciones con pytest.raises y floats con pytest.approx
pytest.raises es, de nuevo, un context manager (08-04 sigue rindiendo). Su extra respecto a assertRaises es el parámetro match=, una expresión regular que debe encontrarse en el mensaje de la excepción:
def test_sin_stock_explica_el_problema(catalogo):
with pytest.raises(StockInsuficienteError, match="La Odisea") as exc_info:
vender(catalogo, "La Odisea", 5)
# exc_info.value es la excepción: verificamos su carga útil (M7)
assert exc_info.value.solicitado == 5
assert exc_info.value.disponible == 4match="La Odisea" garantiza que el mensaje menciona el título — probamos no solo que falla, sino que falla explicándose bien, que era la razón de ser de nuestra jerarquía ErrorPapyrus.
pytest.approx resuelve los floats con una sintaxis que se lee como matemática:
assert 0.1 + 0.2 == pytest.approx(0.3) # True, tolerancia relativa por defecto
assert importe == pytest.approx(20.70, abs=0.01) # tolerancia absoluta de 1 céntimoPara importes monetarios, abs=0.01 (medio camino entre céntimos) es una elección explícita y clara.
Fixtures: el setUp hecho función (y con yield)
La fixture catalogo de arriba merece una segunda mirada. Se declara con @pytest.fixture (¡un decorador, 08-02!) y los tests la reciben pidiéndola como parámetro: pytest ve que el test declara catalogo, busca la fixture de ese nombre, la ejecuta y le inyecta el resultado. Cada test recibe un catálogo recién construido — el aislamiento de setUp, sin clase.
¿Y el tearDown? Aquí pytest conecta con tus generadores de 08-03: una fixture con yield hace el montaje antes del yield y la limpieza después:
@pytest.fixture
def registro_en_log():
# --- montaje (como setUp) ---
logger = configurar_logger_de_prueba()
yield logger # ← aquí se ejecuta el test
# --- limpieza (como tearDown), corre incluso si el test falla ---
logger.handlers.clear()Es literalmente el patrón del generador: el código se pausa en el yield, el test consume el valor, y al terminar el test pytest reanuda la función para ejecutar la limpieza. Setup y teardown en una sola función, con las variables compartidas de forma natural. Si varias suites necesitan la misma fixture, se mueve a un fichero especial tests/conftest.py y pytest la hace visible a todos los tests sin importarla.
tmp_path: probar ficheros sin tocar datos/
cargar_catalogo(ruta: Path) -> dict[str, Libro] y cierre_de_caja(ruta_ventas: Path) -> float trabajan con ficheros (M6). Probarlas contra datos/catalogo.csv real violaría la repetibilidad: el test fallaría el día que Ana añada un libro. pytest regala la fixture tmp_path: un Path (¡el pathlib de M6!) a un directorio temporal único por test, que pytest crea y destruye por ti.
from papyrus.almacen import cargar_catalogo, cierre_de_caja
def test_cargar_catalogo_inexistente_devuelve_vacio(tmp_path):
# Un Path a un fichero que seguro NO existe: contrato de M7 → {} + aviso
resultado = cargar_catalogo(tmp_path / "no_existe.csv")
assert resultado == {}
def test_cierre_de_caja_suma_solo_filas_validas(tmp_path):
# Preparar: un ventas.csv de mentira, con una fila corrupta
ruta = tmp_path / "ventas.csv"
ruta.write_text(
"fecha,titulo,importe\n"
"2026-07-10,Hamlet,20.70\n"
"2026-07-10,La Odisea,BASURA\n" # fila corrupta: debe ignorarse
"2026-07-11,Fausto,21.84\n",
encoding="utf-8",
)
# Actuar + Comprobar: tolerante a corruptas (contrato de M7/M8)
assert cierre_de_caja(ruta) == pytest.approx(42.54)Fíjate en lo que acabamos de conseguir: probamos el comportamiento tolerante a filas corruptas de cierre_de_caja — imposible de verificar contra el ventas.csv real sin ensuciarlo — en un directorio desechable, con el mismo write_text/encoding="utf-8" que dominas desde M6. El test es rápido, aislado y repetible en el portátil de cualquiera de los socios.
Parametrización con @pytest.mark.parametrize
El ejercicio 2 de 09-02 (los cuatro precios de socio, con bucle y subTest) tiene en pytest una forma canónica:
@pytest.mark.parametrize(
"titulo, base, esperado",
[
("La Odisea", 12.50, 12.35),
("Hamlet", 9.95, 9.83),
("El Quijote", 15.90, 15.71),
("Fausto", 21.00, 20.75),
],
)
def test_precio_socio_canonico(titulo, base, esperado):
libro = Libro(titulo, base, 1)
assert libro.precio_final(socio=True) == pytest.approx(esperado, abs=0.01)El decorador genera cuatro tests independientes (uno por tupla): pytest -v los lista como test_precio_socio_canonico[La Odisea-12.5-12.35], etc., y si fallan dos, ves los dos. Añadir un quinto libro al catálogo canónico será añadir una línea a la lista. Datos y lógica, separados.
Ejecutar: pytest -v, -k y compañía
| Comando | Qué hace |
|---|---|
pytest |
Descubre y ejecuta toda la suite |
pytest -v |
Verboso: un renglón por test, con parámetros |
pytest -k "socio" |
Solo los tests cuyo nombre contiene "socio" |
pytest tests/test_almacen.py |
Solo un fichero |
pytest -x |
Se detiene en el primer fallo (para depurar de uno en uno) |
pytest --lf |
last failed: reejecuta solo los que fallaron la última vez |
El ciclo de trabajo natural: pytest completo tras cada cambio; -k o --lf mientras persigues un fallo concreto; -v cuando quieres leer la suite como la lista de contratos que es.
El siguiente escalón de madurez: unittest.mock (solo el mapa)
Sé honesto contigo: hay cosas que esta lección no resuelve. ¿Cómo pruebas una función que llama a una API por la red, consulta la hora actual o depende de algo lento o no determinista? La respuesta profesional son los dobles de prueba (mocks): objetos falsos que sustituyen a la dependencia real durante el test. Python los trae en unittest.mock (compatible con pytest). Es el siguiente escalón en la madurez de un tester, y asoma de forma natural cuando Papyrus hable con servicios externos — cosa que empezará a ocurrir en el módulo 10, con la web. Por ahora, quédate con el nombre y con esta regla: si tu función es difícil de probar porque depende de medio mundo, suele ser el diseño el que pide desacoplarse, no el test el que pide magia.
Errores Comunes y Consejos
- Instalar pytest fuera del venv (o con el venv desactivado): luego
pytest"no existe" o ejecuta contra otro Python. Comprueba conpip listdentro del venv (M1). - Olvidar pedir la fixture como parámetro: si escribes
def test_x():y usascatalogodentro, obtienesNameError. La inyección ocurre solo si el parámetro se llama exactamente como la fixture. - Mutar una fixture creyendo que es compartida (o al revés): por defecto cada test recibe una fixture nueva (
scope="function"). Si cambias ascope="module"por velocidad, los tests vuelven a compartir estado mutable — el fantasma de M4. Con datos mutables, quédate en el scope por defecto. pytest.raises(Exception): capturar la excepción más genérica hace pasar el test aunque salte un error distinto del esperado. Captura la clase concreta (StockInsuficienteError), como te enseñó el módulo 7 conexcept.- Construir rutas a mano en vez de usar
tmp_path:Path("test_tmp.csv")deja basura en el proyecto y colisiona entre tests.tmp_pathes único por test y se limpia solo. - Consejo: ejecuta
pytestantes de empezar a cambiar código, no solo después. Saber que partías de verde convierte cualquier rojo posterior en información pura.
Ejercicios
Ejercicio 1
Reescribe en estilo pytest los tres tests de reponer del ejercicio 1 de 09-02, usando la fixture catalogo (solo necesitas Hamlet) y pytest.raises con match= en el caso del título inexistente (el mensaje de LibroNoEncontradoError contiene el título).
Ejercicio 2
Escribe un test parametrizado test_precio_sin_socio con los cuatro libros canónicos y sus precios finales sin descuento (calcula: base × 1.04, redondeado a 2 decimales) usando pytest.approx.
Ejercicio 3
Usando tmp_path, escribe test_cierre_de_caja_fichero_vacio_devuelve_cero: crea un ventas.csv que contenga solo la cabecera fecha,titulo,importe y comprueba que cierre_de_caja devuelve 0.0.
Soluciones
Ejercicio 1
def test_reponer_suma_stock_y_devuelve_none(catalogo):
assert reponer(catalogo, "Hamlet", 5) is None
assert catalogo["Hamlet"].stock == 11
def test_reponer_inexistente_lanza_error(catalogo):
with pytest.raises(LibroNoEncontradoError, match="Rayuela"):
reponer(catalogo, "Rayuela", 5)
def test_reponer_cero_unidades_no_muta(catalogo):
with pytest.raises(ValueError):
reponer(catalogo, "Hamlet", 0)
assert catalogo["Hamlet"].stock == 6Ejercicio 2 — los esperados: 12.50×1.04=13.00; 9.95×1.04=10.348→10.35; 15.90×1.04=16.536→16.54; 21.00×1.04=21.84.
@pytest.mark.parametrize(
"titulo, base, esperado",
[
("La Odisea", 12.50, 13.00),
("Hamlet", 9.95, 10.35),
("El Quijote", 15.90, 16.54),
("Fausto", 21.00, 21.84),
],
)
def test_precio_sin_socio(titulo, base, esperado):
assert Libro(titulo, base, 1).precio_final() == pytest.approx(esperado, abs=0.01)Ejercicio 3
def test_cierre_de_caja_fichero_vacio_devuelve_cero(tmp_path):
ruta = tmp_path / "ventas.csv"
ruta.write_text("fecha,titulo,importe\n", encoding="utf-8")
assert cierre_de_caja(ruta) == pytest.approx(0.0)Un fichero con solo cabecera es un límite clásico (la familia "límites" de 09-01): cero filas que sumar no debe ser un error, sino 0.0.
Conclusión
La suite de Papyrus ha adelgazado y ganado músculo a la vez: tests que son funciones, assert desnudo con introspección que muestra los valores al fallar, pytest.raises(..., match=) para las excepciones de M7, pytest.approx para los precios, fixtures que inyectan un catálogo fresco (y hacen setup+teardown con un yield, como tus generadores), tmp_path para probar cargar_catalogo y cierre_de_caja sin rozar datos/, y parametrize fijando los cuatro precios de socio canónicos en una tabla. Hasta ahora, sin embargo, hemos seguido siempre el mismo orden: primero el código, después sus tests. En la próxima lección invertiremos la flecha: escribir el test antes que el código — rojo, verde, refactor — y descubrir que los tests no solo verifican el diseño: lo producen. Lo haremos con una pieza nueva de Papyrus que Ana lleva semanas pidiendo: los cupones de descuento.
Curso de Programación en Python
Módulo 1: Introducción a Python
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
