Saltar al contenido
pruebas.py · devschool

Pruebas en Python

Lección 30 de 31 · 13 min de lectura · Actualizado el

En esta lección
  1. Por qué escribir pruebas
  2. El punto de partida: assert
  3. Qué casos probar
  4. unittest: pruebas con la biblioteca estándar
  5. pytest: pruebas con menos código
  6. doctest: ejemplos que se comprueban solos
  7. Escribir código fácil de probar
  8. Errores frecuentes
  9. Resumen

Cada vez que cambias una función, ¿cómo sabes que no has roto nada? Probarla a mano con unos cuantos print funciona el primer día, pero no escala: al mes tienes decenas de funciones y nadie repite todas las comprobaciones. Las pruebas automáticas (o tests) son pequeños programas que comprueban que tu código hace lo que debe, y que puedes repetir en segundos tantas veces como quieras. En esta lección aprenderás a escribirlas con assert, con el módulo unittest de la biblioteca estándar, con pytest (la herramienta más usada) y con doctest.

Por qué escribir pruebas

Una prueba es código que llama a tu función con unos datos concretos y comprueba que el resultado es el esperado. Las que comprueban una pieza pequeña (una función, un método) se llaman pruebas unitarias, y son las que verás aquí.

Ventajas reales, no teóricas:

  • Te atreves a cambiar el código. Si mejoras una función y las pruebas siguen pasando, sabes que no has roto nada. Si alguna falla, sabes exactamente qué.
  • Encuentras los fallos antes. Pensar los casos de prueba te obliga a pensar en lo raro: listas vacías, cero, números negativos…
  • Documentan. Una prueba muestra cómo se usa la función y qué devuelve.

Vamos a probar este código de una tienda online:

def precio_final(precio, descuento=0):
    """Aplica un descuento en porcentaje y redondea a céntimos."""
    if precio < 0:
        raise ValueError("El precio no puede ser negativo")
    if not 0 <= descuento <= 100:
        raise ValueError("El descuento debe estar entre 0 y 100")
    return round(precio * (1 - descuento / 100), 2)


class Carrito:
    def __init__(self):
        self.lineas = {}   # producto -> (precio, cantidad)

    def agregar(self, producto, precio, cantidad=1):
        if cantidad < 1:
            raise ValueError("La cantidad mínima es 1")
        _, actual = self.lineas.get(producto, (precio, 0))
        self.lineas[producto] = (precio, actual + cantidad)

    def total(self):
        return round(sum(p * c for p, c in self.lineas.values()), 2)

    def __len__(self):
        return sum(c for _, c in self.lineas.values())

El punto de partida: assert

assert condición no hace nada si la condición es verdadera y lanza AssertionError si es falsa. Ya lo viste de pasada en excepciones. Con él puedes escribir una prueba sin instalar nada:

def test_precio_final():
    assert precio_final(100) == 100
    assert precio_final(100, 15) == 85
    assert precio_final(19.99, 10) == 17.99   # 17.991 redondeado

test_precio_final()
print("Todo correcto")   # Todo correcto

Si un assert falla, el programa se para con un AssertionError. El problema es que solo ves el primer fallo y sin mucho detalle; además, tienes que llamar tú a cada función de prueba. Para eso existen los frameworks de pruebas.

Cuidado: assert es para pruebas y para comprobar errores de programación, no para validar datos del usuario. Si ejecutas Python con la opción -O, todos los assert desaparecen.

Qué casos probar

Antes de ver herramientas, lo más importante: qué comprobar. Para cada función, piensa en tres grupos de casos:

GrupoEjemplo con precio_final
Caso normalprecio_final(100, 15) devuelve 85.0
Casos límitedescuento 0 y 100, precio 0, decimales que hay que redondear
Casos de errorprecio negativo, descuento 120: debe lanzar ValueError

Los fallos se esconden sobre todo en los límites: el primer y el último valor válidos, la lista vacía, el cero. Y los casos de error también se prueban: que una función falle cuando debe es parte de su trabajo.

Cada prueba sigue el mismo esquema, conocido como AAA: Arrange (preparar los datos), Act (ejecutar lo que pruebas) y Assert (comprobar el resultado).

unittest: pruebas con la biblioteca estándar

unittest viene con Python, así que funciona en cualquier sitio. Se organiza con clases:

  • Creas una clase que hereda de unittest.TestCase.
  • Cada método cuyo nombre empieza por test es una prueba.
  • En lugar de assert, usas métodos como self.assertEqual(a, b), que dan mensajes de error mucho más claros.
import unittest

class TestPrecioFinal(unittest.TestCase):
    def test_sin_descuento(self):
        self.assertEqual(precio_final(80), 80)

    def test_con_descuento(self):
        self.assertEqual(precio_final(100, 15), 85)

    def test_descuentos_limite(self):
        self.assertEqual(precio_final(50, 0), 50)
        self.assertEqual(precio_final(50, 100), 0)

    def test_redondea_a_centimos(self):
        self.assertEqual(precio_final(9.99, 33), 6.69)

    def test_precio_negativo_da_error(self):
        with self.assertRaises(ValueError):
            precio_final(-5)

    def test_descuento_fuera_de_rango(self):
        with self.assertRaises(ValueError) as contexto:
            precio_final(100, 120)
        self.assertIn("entre 0 y 100", str(contexto.exception))

assertRaises se usa con with: la prueba pasa solo si el bloque lanza esa excepción. Es un gestor de contexto como los que viste en métodos especiales. Con as contexto puedes revisar además el mensaje del error.

Ejecutar las pruebas

En un proyecto real, las pruebas van en su propio archivo (por ejemplo, test_tienda.py) y se lanzan desde la terminal con:

python -m unittest -v

Python busca los archivos que empiezan por test, ejecuta todas las pruebas y te enseña el resultado. Aquí, para poder ejecutarlas en la web, las lanzamos desde el propio código:

suite = unittest.TestLoader().loadTestsFromTestCase(TestPrecioFinal)
resultado = unittest.TextTestRunner(verbosity=2).run(suite)
print(resultado.wasSuccessful())   # True

La salida es parecida a esta (el tiempo varía):

test_con_descuento (__main__.TestPrecioFinal.test_con_descuento) ... ok
test_descuento_fuera_de_rango (__main__.TestPrecioFinal.test_descuento_fuera_de_rango) ... ok
test_descuentos_limite (__main__.TestPrecioFinal.test_descuentos_limite) ... ok
test_precio_negativo_da_error (__main__.TestPrecioFinal.test_precio_negativo_da_error) ... ok
test_redondea_a_centimos (__main__.TestPrecioFinal.test_redondea_a_centimos) ... ok
test_sin_descuento (__main__.TestPrecioFinal.test_sin_descuento) ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.001s

OK

Cuando una prueba falla

Imagina que alguien “mejora” precio_final y se le olvida redondear. La prueba lo detecta y te dice exactamente qué esperaba y qué ha recibido:

$ python -m unittest
....F.
======================================================================
FAIL: test_redondea_a_centimos (test_tienda.TestPrecioFinal.test_redondea_a_centimos)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/ana/tienda/test_tienda.py", line 16, in test_redondea_a_centimos
    self.assertEqual(precio_final(9.99, 33), 6.69)
AssertionError: 6.6933 != 6.69

----------------------------------------------------------------------
Ran 6 tests in 0.001s

FAILED (failures=1)

FAIL significa que una comprobación no se ha cumplido. Si ves ERROR, la prueba ha lanzado una excepción inesperada (por ejemplo, un TypeError): normalmente es un fallo en el código, o en la propia prueba.

setUp: preparar cada prueba

Si varias pruebas necesitan los mismos datos iniciales, ponlos en setUp. Se ejecuta antes de cada prueba, así que cada una empieza con un objeto nuevo y las pruebas no se influyen entre sí.

class TestCarrito(unittest.TestCase):
    def setUp(self):
        self.carrito = Carrito()
        self.carrito.agregar("Camiseta", 15.0, 2)

    def test_total(self):
        self.assertEqual(self.carrito.total(), 30.0)

    def test_agregar_el_mismo_producto_suma_cantidades(self):
        self.carrito.agregar("Camiseta", 15.0)
        self.assertEqual(len(self.carrito), 3)

    def test_carrito_vacio(self):
        vacio = Carrito()
        self.assertEqual(vacio.total(), 0)
        self.assertFalse(vacio)

    def test_cantidad_cero_da_error(self):
        with self.assertRaises(ValueError):
            self.carrito.agregar("Gorra", 9.5, 0)

suite = unittest.TestLoader().loadTestsFromTestCase(TestCarrito)
unittest.TextTestRunner().run(suite)
# ....
# ----------------------------------------------------------------------
# Ran 4 tests in 0.001s
#
# OK

Sin verbosity=2, cada prueba superada se muestra como un punto (.); una fallida, como F, y una que lanza un error inesperado, como E.

Aunque test_agregar_el_mismo_producto... añade una camiseta, test_total sigue viendo solo dos: cada prueba recibe su propio carrito recién creado. También existe tearDown, que se ejecuta después de cada prueba y sirve para limpiar (borrar un fichero temporal, cerrar una conexión).

Los métodos de comprobación más usados:

MétodoComprueba que…
assertEqual(a, b) / assertNotEqual(a, b)a == b / a != b
assertTrue(x) / assertFalse(x)x es verdadero / falso
assertIn(a, b)a in b
assertIsNone(x)x is None
assertAlmostEqual(a, b)a y b son iguales hasta 7 decimales
assertRaises(Error)el bloque with lanza Error

Consejo: al comparar números decimales usa assertAlmostEqual. 0.1 + 0.2 == 0.3 es False por cómo se guardan los decimales en binario, y una prueba con assertEqual fallaría sin que tu código tenga la culpa.

pytest: pruebas con menos código

pytest no viene con Python, pero es la herramienta que usa la mayoría de proyectos. Se instala con pip, mejor dentro de un entorno virtual:

python -m pip install pytest

Con pytest no hace falta ninguna clase: las pruebas son funciones normales cuyo nombre empieza por test_, y se usa el assert de siempre. Este sería el archivo test_tienda.py:

import pytest
from tienda import precio_final, Carrito

def test_con_descuento():
    assert precio_final(100, 15) == 85

def test_redondea_a_centimos():
    assert precio_final(9.99, 33) == 6.69

def test_descuento_fuera_de_rango():
    with pytest.raises(ValueError, match="entre 0 y 100"):
        precio_final(100, 120)

def test_carrito_suma_cantidades():
    carrito = Carrito()
    carrito.agregar("Camiseta", 15.0, 2)
    carrito.agregar("Camiseta", 15.0)
    assert len(carrito) == 3
    assert carrito.total() == 45.0

Lo ejecutas desde la carpeta del proyecto con pytest (o pytest -v para ver cada prueba). pytest busca solo los archivos test_*.py y las funciones test_*. La salida queda así (resumida):

$ pytest -v
======================= test session starts =======================
collected 4 items

test_tienda.py::test_con_descuento PASSED                    [ 25%]
test_tienda.py::test_redondea_a_centimos PASSED              [ 50%]
test_tienda.py::test_descuento_fuera_de_rango PASSED         [ 75%]
test_tienda.py::test_carrito_suma_cantidades PASSED          [100%]

======================== 4 passed in 0.02s ========================

Lo mejor de pytest es que, aunque uses un assert normal, cuando falla te muestra los valores de cada lado:

    def test_con_descuento():
>       assert precio_final(100, 15) == 80
E       assert 85.0 == 80
E        +  where 85.0 = precio_final(100, 15)

parametrize: muchos casos, una sola prueba

Cuando quieres comprobar la misma función con muchos datos, no copies la prueba diez veces. @pytest.mark.parametrize ejecuta la función una vez por cada fila de datos, y cada fila cuenta como una prueba distinta:

@pytest.mark.parametrize("precio, descuento, esperado", [
    (100, 0, 100),
    (100, 100, 0),
    (80, 25, 60),
    (0, 50, 0),
    (19.99, 10, 17.99),
])
def test_precio_final_varios(precio, descuento, esperado):
    assert precio_final(precio, descuento) == esperado

Si falla una fila, pytest te dice cuál (por ejemplo, test_precio_final_varios[80-25-60]) y sigue con las demás.

Fixtures: datos preparados

Una fixture es el equivalente de setUp en pytest: una función marcada con @pytest.fixture que prepara algo. Las pruebas que la necesitan solo tienen que pedirla como parámetro, con el mismo nombre:

@pytest.fixture
def carrito_con_camisetas():
    carrito = Carrito()
    carrito.agregar("Camiseta", 15.0, 2)
    return carrito

def test_total(carrito_con_camisetas):
    assert carrito_con_camisetas.total() == 30.0

def test_cantidad_cero(carrito_con_camisetas):
    with pytest.raises(ValueError):
        carrito_con_camisetas.agregar("Gorra", 9.5, 0)

pytest crea un carrito nuevo para cada prueba que lo pide. Además, pytest ejecuta sin cambios las pruebas escritas con unittest, así que puedes empezar con una y pasar a la otra cuando quieras.

doctest: ejemplos que se comprueban solos

doctest busca en los docstrings líneas que parecen una sesión de la consola de Python (empiezan por >>>), las ejecuta y comprueba que el resultado coincide con la línea de debajo. Así, los ejemplos de tu documentación nunca se quedan desfasados.

def iniciales(nombre_completo):
    """Devuelve las iniciales de un nombre en mayúsculas.

    >>> iniciales("lucía gómez ruiz")
    'LGR'
    >>> iniciales("Íñigo")
    'Í'
    >>> iniciales("")
    ''
    """
    return "".join(parte[0].upper() for parte in nombre_completo.split())

import doctest
doctest.run_docstring_examples(iniciales, globals(), name="iniciales")
print("Sin errores")   # Sin errores

Si todos los ejemplos coinciden, doctest no muestra nada. Si alguno no coincide, enseña el ejemplo, lo esperado (Expected) y lo obtenido (Got). En un archivo normal se suele lanzar desde la terminal:

python -m doctest tienda.py -v

El -v muestra cada ejemplo comprobado; sin él, solo verás algo si hay fallos.

doctest compara el texto que se mostraría en la consola, carácter a carácter. Por eso es ideal para ejemplos cortos de documentación, pero incómodo para pruebas serias: un espacio de más, el orden de un conjunto o un decimal de diferencia lo hacen fallar. Para lo demás, usa unittest o pytest.

Escribir código fácil de probar

Las pruebas no solo comprueban el código: también te empujan a escribirlo mejor. Compara:

def mostrar_resumen_malo():
    total = float(input("Total: "))
    print(f"Con IVA: {total * 1.21:.2f} €")

def con_iva(total):
    return round(total * 1.21, 2)

print(con_iva(100))   # 121.0

La primera función pide datos con input() y escribe con print(): para probarla habría que simular el teclado y capturar la pantalla. La segunda recibe datos y devuelve un resultado, así que una prueba es una línea: assert con_iva(100) == 121.0. La regla: separa la lógica (cálculos, decisiones) de la entrada y salida (input, print, ficheros). Es justo lo que harás en el proyecto de la agenda.

Algunas costumbres más:

  • Una idea por prueba, con un nombre que diga qué comprueba: test_descuento_fuera_de_rango se entiende sin leer el código.
  • Pruebas independientes: ninguna debe depender de que otra se haya ejecutado antes.
  • Ejecútalas a menudo, sobre todo antes de subir cambios. En proyectos profesionales se lanzan solas en cada git push.
  • Algunos programadores escriben la prueba antes que el código: primero una prueba que falla, luego el código mínimo para que pase y después lo mejoran. Esta forma de trabajar se llama TDD (Test-Driven Development).

Errores frecuentes

  • Nombres que no empiezan por test: ni unittest ni pytest encuentran def comprobar_total():. Las funciones, métodos y archivos deben empezar por test.
  • Probar solo el caso feliz: los fallos están en los límites (cero, vacío, máximo) y en los datos incorrectos.
  • Comparar decimales con ==: usa assertAlmostEqual o pytest.approx(0.3).
  • Pruebas que dependen unas de otras: si una modifica datos compartidos, otra puede fallar según el orden. Prepara los datos en setUp o en una fixture.
  • Llamar a la función dentro de assertRaises sin with: self.assertRaises(ValueError, precio_final(-5)) ejecuta precio_final(-5) antes de que assertRaises pueda capturar nada. Usa la forma con with.
  • Olvidar que assert desaparece con -O: no lo uses para validar datos en tu programa, solo en las pruebas.

Resumen

HerramientaCómo se escribeCómo se ejecuta
assertassert precio_final(100, 15) == 85Llamando a la función de prueba
unittestClase que hereda de unittest.TestCase, métodos test_* y self.assertEqual(...)python -m unittest -v
Preparar datos (unittest)setUp y tearDownAntes y después de cada prueba
Comprobar erroreswith self.assertRaises(ValueError): / with pytest.raises(ValueError):Dentro de la prueba
pytestFunciones test_* con assert normalpytest -v
Muchos casos@pytest.mark.parametrize(...)Una prueba por fila
Preparar datos (pytest)@pytest.fixture y pedirla como parámetroAntes de cada prueba que la pide
doctestEjemplos >>> en el docstringpython -m doctest archivo.py -v

En la siguiente lección juntarás clases, ficheros JSON y pruebas en un programa completo: el proyecto de la agenda de contactos.

Pon a prueba lo que has aprendido

[Python] En una clase unittest.TestCase hay un setUp que crea self.lista = [] y dos pruebas: test_a añade un elemento y comprueba len == 1; test_b comprueba len == 0. ¿Qué ocurre?

[Python] Una prueba con unittest contiene self.assertRaises(ValueError, precio_final(-5)). ¿Qué ocurre?

[Python] ¿Por qué falla self.assertEqual(0.1 + 0.2, 0.3) y qué se usa en su lugar?

[Python] Un compañero escribe un método def comprobar_total(self): dentro de su TestCase. ¿Qué pasa al ejecutar python -m unittest?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.