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

  1. Organizar el proyecto: la carpeta tests/
  2. Anatomía de un TestCase
  3. Las aserciones: assertEqual, assertAlmostEqual y compañía
  4. Probar excepciones: assertRaises como context manager
  5. setUp y tearDown: un catálogo fresco por test
  6. Ejecutar la suite: python -m unittest
  7. La suite real de Papyrus: test_modelos.py y test_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.py

Dos 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.pymodelos.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 directamente

Desglose:

  • 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. Si test_precio_sin_socio... falla, test_precio_socio... se ejecuta igualmente — adiós al "se para en el primer fallo" del assert a pelo.
  • self.assertEqual(a, b) sustituye a assert a == b, y no es un capricho: cuando falla, el informe muestra ambos valores (13.0 != 12.99), cosa que un assert desnudo 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_iva se lee como una frase. Cuando falle dentro de seis meses, el nombre solo ya te dirá qué contrato se rompió. Huye de test_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étodo test_. 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_stock

La 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 (-v lista los nombres).
  • self.assertRaises(ValueError, vender(catalogo, "Hamlet", 0)). Esto llama a vender antes de que assertRaises pueda 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 en setUp): todos los tests mutarían el mismo diccionario, resucitando el problema de aliasing de M4.
  • assertTrue(a == b) en lugar de assertEqual(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: lanza python -m unittest desde la raíz del proyecto (donde está la carpeta papyrus/), no desde dentro de tests/.
  • 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

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados