Pruebas en Python
En esta lección
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:
assertes para pruebas y para comprobar errores de programación, no para validar datos del usuario. Si ejecutas Python con la opción-O, todos losassertdesaparecen.
Qué casos probar
Antes de ver herramientas, lo más importante: qué comprobar. Para cada función, piensa en tres grupos de casos:
| Grupo | Ejemplo con precio_final |
|---|---|
| Caso normal | precio_final(100, 15) devuelve 85.0 |
| Casos límite | descuento 0 y 100, precio 0, decimales que hay que redondear |
| Casos de error | precio 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
testes una prueba. - En lugar de
assert, usas métodos comoself.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étodo | Comprueba 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.3esFalsepor cómo se guardan los decimales en binario, y una prueba conassertEqualfallarí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_rangose 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: niunittestni pytest encuentrandef comprobar_total():. Las funciones, métodos y archivos deben empezar portest. - 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
==: usaassertAlmostEqualopytest.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
setUpo en una fixture. - Llamar a la función dentro de
assertRaisessinwith:self.assertRaises(ValueError, precio_final(-5))ejecutaprecio_final(-5)antes de queassertRaisespueda capturar nada. Usa la forma conwith. - Olvidar que
assertdesaparece con-O: no lo uses para validar datos en tu programa, solo en las pruebas.
Resumen
| Herramienta | Cómo se escribe | Cómo se ejecuta |
|---|---|---|
assert | assert precio_final(100, 15) == 85 | Llamando a la función de prueba |
unittest | Clase que hereda de unittest.TestCase, métodos test_* y self.assertEqual(...) | python -m unittest -v |
| Preparar datos (unittest) | setUp y tearDown | Antes y después de cada prueba |
| Comprobar errores | with self.assertRaises(ValueError): / with pytest.raises(ValueError): | Dentro de la prueba |
| pytest | Funciones test_* con assert normal | pytest -v |
| Muchos casos | @pytest.mark.parametrize(...) | Una prueba por fila |
| Preparar datos (pytest) | @pytest.fixture y pedirla como parámetro | Antes de cada prueba que la pide |
| doctest | Ejemplos >>> en el docstring | python -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
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.