El script artesanal de la lección anterior probaba Papyrus, pero se detenía en el primer fallo, contaminaba el catálogo entre comprobaciones y había que lanzarlo a mano. Python trae de serie la solución: unittest, el framework de pruebas de la biblioteca estándar (inspirado en el clásico JUnit de Java). Sin instalar nada — ni siquiera hace falta pip — vas a convertir aquellas comprobaciones sueltas en una suite organizada: clases de test, aserciones especializadas, un escenario fresco para cada prueba y un ejecutor que lo descubre y corre todo con un comando.
Contenido
- Organizar el proyecto: la carpeta
tests/ - Anatomía de un
TestCase - Las aserciones:
assertEqual,assertAlmostEqualy compañía - Probar excepciones:
assertRaisescomo context manager setUpytearDown: un catálogo fresco por test- Ejecutar la suite:
python -m unittest - La suite real de Papyrus:
test_modelos.pyytest_almacen.py
Organizar el proyecto: la carpeta tests/
La convención universal es una carpeta tests/ junto al paquete, no dentro de él: el código de producción no debe cargar con sus pruebas. Nuestro proyecto queda así:
papyrus_proyecto/
├── papyrus/ ← el paquete (módulos 3-8)
│ ├── __init__.py
│ ├── errores.py
│ ├── modelos.py
│ ├── almacen.py
│ └── caja.py
├── datos/
│ ├── catalogo.csv
│ └── ventas.csv
└── tests/ ← NUEVO: la suite de pruebas
├── __init__.py ← convierte tests/ en paquete (ayuda al descubrimiento)
├── test_modelos.py ← prueba papyrus/modelos.py
└── test_almacen.py ← prueba papyrus/almacen.pyDos convenciones importan porque el ejecutor las usa para descubrir las pruebas automáticamente: los ficheros empiezan por test_, y dentro, los métodos de prueba también. Un fichero de test por módulo probado (test_modelos.py ↔ modelos.py) mantiene la simetría fácil de navegar.
Anatomía de un TestCase
En unittest, los tests se agrupan en clases que heredan de unittest.TestCase (herencia, tal como la viste en M5). Cada método cuyo nombre empiece por test_ es una prueba independiente:
# tests/test_modelos.py
import unittest
from papyrus.modelos import Libro
class TestPrecioFinal(unittest.TestCase):
"""Pruebas del cálculo de precio con IVA y descuento de socio."""
def test_precio_sin_socio_aplica_solo_iva(self):
# Preparar
libro = Libro("La Odisea", 12.50, 4)
# Actuar
precio = libro.precio_final()
# Comprobar: 12.50 × 1.04 = 13.00
self.assertEqual(precio, 13.00)
def test_precio_socio_aplica_descuento_e_iva(self):
libro = Libro("La Odisea", 12.50, 4)
precio = libro.precio_final(socio=True)
# 12.50 × 0.95 × 1.04 = 12.35 — el bug de Ana moriría aquí
self.assertAlmostEqual(precio, 12.35, places=2)
if __name__ == "__main__":
unittest.main() # permite ejecutar este fichero directamenteDesglose:
- La clase agrupa tests relacionados; puedes tener varias por fichero (
TestPrecioFinal,TestValidacionLibro...). - Cada método
test_es un test aislado: el ejecutor los corre por separado y reporta cada uno. Sitest_precio_sin_socio...falla,test_precio_socio...se ejecuta igualmente — adiós al "se para en el primer fallo" delasserta pelo. self.assertEqual(a, b)sustituye aassert a == b, y no es un capricho: cuando falla, el informe muestra ambos valores (13.0 != 12.99), cosa que unassertdesnudo no hace.- El
if __name__ == "__main__": unittest.main()final es nuestro viejo conocido de M3, ascendido: ya no lanza una demo que hay que mirar, sino la suite que se juzga sola. - Fíjate en los nombres descriptivos:
test_precio_socio_aplica_descuento_e_ivase lee como una frase. Cuando falle dentro de seis meses, el nombre solo ya te dirá qué contrato se rompió. Huye detest_1,test_precio2.
Las aserciones: assertEqual, assertAlmostEqual y compañía
TestCase ofrece decenas de métodos assert*. Estos son los que usarás el 95 % del tiempo:
| Método | Comprueba | Ejemplo en Papyrus |
|---|---|---|
assertEqual(a, b) |
a == b |
assertEqual(importe, 20.70) |
assertNotEqual(a, b) |
a != b |
Precio socio ≠ precio normal |
assertAlmostEqual(a, b, places=2) |
Igualdad de floats con tolerancia | Cualquier precio calculado |
assertTrue(x) / assertFalse(x) |
Veracidad | assertTrue(libro.hay_stock()) |
assertIn(x, col) |
x in col |
assertIn("Hamlet", catalogo) |
assertIsNone(x) / assertIsNotNone(x) |
x is None |
buscar_libro con título inexistente |
assertIs(a, b) |
Identidad (a is b) |
Comprobar que devuelve el mismo objeto |
assertRaises(Exc) |
Que se lanza la excepción | StockInsuficienteError |
assertGreater(a, b) / assertLess(a, b) |
Comparaciones | Stock tras reponer |
Dos merecen comentario:
assertAlmostEqual y los floats. Desde M1 sabes que 0.1 + 0.2 == 0.3 es False: los floats binarios no representan exactamente los decimales. Nuestros precios se redondean a 2 decimales dentro de precio_final, así que assertEqual suele funcionar... hasta que un refactor cambie dónde se redondea y el test falle por 12.350000000000001. assertAlmostEqual(precio, 12.35, places=2) compara redondeando a 2 decimales la diferencia, y es la elección robusta para todo importe monetario en los tests.
assertEqual entre objetos. Recuerda de M5 que @dataclass genera __eq__ comparando campo a campo. Eso hace trivial comprobar objetos enteros:
self.assertEqual(
buscar_libro(catalogo, "Hamlet"),
Libro("Hamlet", 9.95, 6), # __eq__ generado: compara titulo, precio y stock
)Sin la dataclass tendrías que comparar atributo a atributo. Es uno de esos dividendos que M5 nos paga ahora.
Probar excepciones: assertRaises como context manager
En el módulo 7 blindamos vender con excepciones; son parte del contrato y merecen tests. La forma moderna de assertRaises es como context manager — el mismo protocolo __enter__/__exit__ que construiste a mano en 08-04:
from papyrus.errores import StockInsuficienteError
def test_vender_sin_stock_lanza_error(self):
catalogo = {"La Odisea": Libro("La Odisea", 12.50, 4)}
with self.assertRaises(StockInsuficienteError) as ctx:
vender(catalogo, "La Odisea", 5) # solo hay 4
# La excepción capturada queda en ctx.exception: podemos inspeccionarla
self.assertEqual(ctx.exception.solicitado, 5)
self.assertEqual(ctx.exception.disponible, 4)¿Ves la elegancia? El __exit__ del context manager recibe la excepción (como aprendiste en 08-04) y decide: si es la esperada, la traga y el test sigue; si no salta ninguna, el test falla con "StockInsuficienteError not raised". Se acabó el baile try/except/assert False de la lección anterior. Y como StockInsuficienteError guarda titulo, solicitado y disponible (M7), podemos verificar que la excepción lleva la información correcta, no solo que salta.
setUp y tearDown: un catálogo fresco por test
El script artesanal tenía un vicio: los tests compartían el catálogo, y el orden importaba (M4 te enseñó lo traicioneras que son las mutaciones compartidas). unittest lo resuelve con dos métodos especiales:
setUp(self): se ejecuta antes de cada métodotest_. Ideal para montar el escenario.tearDown(self): se ejecuta después de cada test, incluso si falló. Ideal para limpiar (cerrar ficheros, borrar temporales).
class TestVender(unittest.TestCase):
def setUp(self):
"""Catálogo canónico RECIÉN CREADO antes de cada test."""
self.catalogo = {
"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_vender_descuenta_stock(self):
vender(self.catalogo, "Hamlet", 2)
self.assertEqual(self.catalogo["Hamlet"].stock, 4)
def test_vender_devuelve_importe_con_iva(self):
importe = vender(self.catalogo, "Hamlet", 2)
# Este test recibe OTRO catálogo fresco: el stock vuelve a ser 6
self.assertAlmostEqual(importe, 20.70, places=2)Aunque test_vender_descuenta_stock deja el stock en 4, el siguiente test parte de 6: setUp corre de nuevo. Cada test es una isla — la cualidad "aislado" de 09-01, gratis. (Existen también setUpClass/tearDownClass, una sola vez por clase, para recursos caros; con nuestro catálogo en memoria no hacen falta.)
Ejecutar la suite: python -m unittest
Desde la raíz del proyecto (con el venv de M1 activado):
# Descubre y ejecuta TODO lo que empiece por test_ bajo tests/
python -m unittest discover tests
# Modo verboso: lista cada test con su resultado
python -m unittest discover tests -v
# Solo un fichero, una clase o un test concreto
python -m unittest tests.test_almacen
python -m unittest tests.test_almacen.TestVender
python -m unittest tests.test_almacen.TestVender.test_vender_descuenta_stockLa salida cuenta la historia entera: un punto por test pasado, F por fallo (una aserción no se cumplió), E por error (el test reventó con una excepción no esperada), y al final el resumen:
...F.. ====================================================================== FAIL: test_precio_socio_aplica_descuento_e_iva (tests.test_modelos.TestPrecioFinal) ---------------------------------------------------------------------- AssertionError: 12.36 != 12.35 within 2 places (0.01 difference) ---------------------------------------------------------------------- Ran 6 tests in 0.004s FAILED (failures=1, errors=0)
Ahí está el bug de Ana, cazado en 4 milisegundos, con los dos valores a la vista. Esto es lo que el assert a pelo no podía darte.
La suite real de Papyrus: test_modelos.py y test_almacen.py
Juntémoslo todo. Primero los modelos — el caso feliz, la validación del __post_init__ y hay_stock:
# tests/test_modelos.py
import unittest
from papyrus.modelos import Libro
class TestValidacionLibro(unittest.TestCase):
def test_libro_valido_se_crea_con_sus_datos(self):
libro = Libro("Fausto", 21.00, 10)
self.assertEqual(libro, Libro("Fausto", 21.00, 10)) # __eq__ de dataclass
def test_stock_por_defecto_es_cero(self):
self.assertEqual(Libro("Fausto", 21.00).stock, 0)
def test_precio_negativo_lanza_valueerror(self):
with self.assertRaises(ValueError):
Libro("Fausto", -21.00, 10) # __post_init__ lo veta (M5)
def test_hay_stock_es_falso_a_cero(self):
self.assertFalse(Libro("Fausto", 21.00, 0).hay_stock())
self.assertTrue(Libro("Fausto", 21.00, 1).hay_stock())Y el almacén — feliz, límites y los tres errores del contrato de vender:
# tests/test_almacen.py
import unittest
from papyrus.almacen import buscar_libro, obtener_libro, vender
from papyrus.errores import LibroNoEncontradoError, StockInsuficienteError
from papyrus.modelos import Libro
class TestVender(unittest.TestCase):
def setUp(self):
self.catalogo = {
"La Odisea": Libro("La Odisea", 12.50, 4),
"Hamlet": Libro("Hamlet", 9.95, 6),
}
def test_venta_feliz_devuelve_importe_y_descuenta(self):
importe = vender(self.catalogo, "Hamlet", 2)
self.assertAlmostEqual(importe, 20.70, places=2)
self.assertEqual(self.catalogo["Hamlet"].stock, 4)
def test_vender_todo_el_stock_deja_cero(self):
vender(self.catalogo, "La Odisea", 4) # límite exacto
self.assertEqual(self.catalogo["La Odisea"].stock, 0)
def test_sin_stock_lanza_error_y_no_muta(self):
with self.assertRaises(StockInsuficienteError):
vender(self.catalogo, "La Odisea", 5)
self.assertEqual(self.catalogo["La Odisea"].stock, 4) # ¡intacto!
def test_titulo_inexistente_lanza_libro_no_encontrado(self):
with self.assertRaises(LibroNoEncontradoError):
vender(self.catalogo, "Rayuela", 1)
def test_unidades_invalidas(self):
with self.assertRaises(ValueError):
vender(self.catalogo, "Hamlet", 0)
with self.assertRaises(TypeError):
vender(self.catalogo, "Hamlet", "dos")
class TestBusqueda(unittest.TestCase):
def setUp(self):
self.catalogo = {"Hamlet": Libro("Hamlet", 9.95, 6)}
def test_buscar_inexistente_devuelve_none(self):
self.assertIsNone(buscar_libro(self.catalogo, "Rayuela"))
def test_obtener_inexistente_lanza_error(self):
with self.assertRaises(LibroNoEncontradoError):
obtener_libro(self.catalogo, "Rayuela")Nueve tests que fijan por escrito el contrato completo que construimos entre los módulos 5 y 8: las firmas que mypy verifica en tipos, ahora verificadas en comportamiento. Si mañana Ana toca vender, python -m unittest discover tests le dirá en milisegundos si rompió algo.
Errores Comunes y Consejos
- Olvidar el prefijo
test_en un método. El ejecutor lo ignora en silencio: la suite pasa... porque ese test no existe. Si un test "nunca falla", comprueba primero que se está ejecutando (-vlista los nombres). self.assertRaises(ValueError, vender(catalogo, "Hamlet", 0)). Esto llama avenderantes de queassertRaisespueda vigilar: la excepción revienta el test como error. Usa la forma context manager (with self.assertRaises(...):) y llama dentro del bloque.- Compartir estado mutable a nivel de clase (
catalogo = {...}como atributo de clase en lugar de crearlo ensetUp): todos los tests mutarían el mismo diccionario, resucitando el problema de aliasing de M4. assertTrue(a == b)en lugar deassertEqual(a, b). Funciona, pero al fallar solo dice "False is not true" en vez de mostrarte ambos valores. Usa siempre la aserción más específica disponible.ModuleNotFoundError: No module named 'papyrus'al ejecutar los tests: lanzapython -m unittestdesde la raíz del proyecto (donde está la carpetapapyrus/), no desde dentro detests/.- Consejo: nombra cada test como una afirmación del contrato (
test_sin_stock_lanza_error_y_no_muta). El informe de fallos se convierte en una lista de promesas incumplidas, legible sin abrir código.
Ejercicios
Ejercicio 1
Escribe TestReponer en tests/test_almacen.py, con setUp propio, cubriendo la tabla que diseñaste en el ejercicio 2 de 09-01: (a) reponer 5 Hamlet sube el stock de 6 a 11 y la función devuelve None; (b) reponer un título inexistente lanza LibroNoEncontradoError; (c) unidades=0 lanza ValueError y el stock no cambia.
Ejercicio 2
Añade a TestPrecioFinal un test parametrizado a mano, test_precios_socio_canonicos, que compruebe con assertAlmostEqual los cuatro precios de socio del catálogo canónico (12.35, 9.83, 15.71, 20.75) usando un bucle sobre una lista de tuplas (titulo, precio_base, esperado). Pista: usa with self.subTest(titulo=titulo): dentro del bucle para que, si fallan dos precios, el informe muestre ambos.
Ejercicio 3
Escribe un test que demuestre que StockInsuficienteError transporta la información correcta: al vender 9 Hamlet (stock 6), la excepción capturada debe tener titulo == "Hamlet", solicitado == 9 y disponible == 6.
Soluciones
Ejercicio 1
class TestReponer(unittest.TestCase):
def setUp(self):
self.catalogo = {"Hamlet": Libro("Hamlet", 9.95, 6)}
def test_reponer_suma_stock_y_devuelve_none(self):
resultado = reponer(self.catalogo, "Hamlet", 5)
self.assertIsNone(resultado) # -> None, como anota su firma
self.assertEqual(self.catalogo["Hamlet"].stock, 11)
def test_reponer_inexistente_lanza_error(self):
with self.assertRaises(LibroNoEncontradoError):
reponer(self.catalogo, "Rayuela", 5)
def test_reponer_cero_unidades_lanza_valueerror_y_no_muta(self):
with self.assertRaises(ValueError):
reponer(self.catalogo, "Hamlet", 0)
self.assertEqual(self.catalogo["Hamlet"].stock, 6)Ejercicio 2
def test_precios_socio_canonicos(self):
casos = [
("La Odisea", 12.50, 12.35),
("Hamlet", 9.95, 9.83),
("El Quijote", 15.90, 15.71),
("Fausto", 21.00, 20.75),
]
for titulo, base, esperado in casos:
with self.subTest(titulo=titulo):
libro = Libro(titulo, base, 1)
self.assertAlmostEqual(libro.precio_final(socio=True), esperado, places=2)subTest hace que cada vuelta del bucle se informe por separado: sin él, el primer precio que falle ocultaría los demás. (Adelanto: en la próxima lección, pytest.mark.parametrize hace esto mismo con más elegancia.)
Ejercicio 3
def test_stock_insuficiente_transporta_los_datos(self):
catalogo = {"Hamlet": Libro("Hamlet", 9.95, 6)}
with self.assertRaises(StockInsuficienteError) as ctx:
vender(catalogo, "Hamlet", 9)
self.assertEqual(ctx.exception.titulo, "Hamlet")
self.assertEqual(ctx.exception.solicitado, 9)
self.assertEqual(ctx.exception.disponible, 6)Conclusión
Papyrus ya tiene su primera suite de verdad: tests/test_modelos.py y tests/test_almacen.py fijan el contrato de Libro, vender, reponer y las búsquedas — casos felices, límites y las excepciones de M7 incluidas — con un catálogo fresco por test gracias a setUp, floats comparados con assertAlmostEqual y excepciones vigiladas por el context manager assertRaises. Todo con la biblioteca estándar y un solo comando: python -m unittest discover tests. Pero quizá notaste cierta ceremonia: heredar de TestCase, memorizar veinte métodos assert*, el self. omnipresente. La comunidad Python pensó lo mismo, y por eso la herramienta dominante en el mundo real es otra: pytest, donde un test es una función normal y la aserción es el assert de toda la vida — pero con superpoderes. Se instala con pip en el venv, exactamente como aprendiste en el módulo 1. Es la próxima lección.
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
