Muchas funciones no solo devuelven resultados correctos. También deben rechazar datos inválidos. En Python, una forma habitual de indicar ese problema es lanzar una excepción, por ejemplo ValueError.
En este tema probaremos funciones que pueden fallar de manera esperada. Veremos cómo comprobar que una excepción ocurre, que es del tipo correcto y que el mensaje ayuda a entender el problema.
Crea un proyecto nuevo para este tema:
mkdir excepciones-demo
cd excepciones-demo
Usaremos unittest, incluido en Python, por lo que no hace falta instalar dependencias externas.
Crea un archivo llamado validaciones.py:
def dividir(a, b):
if b == 0:
raise ValueError("No se puede dividir por cero")
return a / b
def calcular_descuento(precio, porcentaje):
if precio < 0:
raise ValueError("El precio no puede ser negativo")
if porcentaje < 0 or porcentaje > 100:
raise ValueError("El porcentaje debe estar entre 0 y 100")
return precio - (precio * porcentaje / 100)
def validar_nombre(nombre):
if not nombre or not nombre.strip():
raise ValueError("El nombre es obligatorio")
return nombre.strip().title()
def obtener_item(lista, indice):
if indice < 0:
raise IndexError("El índice no puede ser negativo")
if indice >= len(lista):
raise IndexError("El índice está fuera de rango")
return lista[indice]
def convertir_a_entero(texto):
try:
return int(texto)
except ValueError:
raise ValueError("El valor debe ser un número entero")
Estas funciones tienen dos caminos posibles: devolver un valor válido o lanzar una excepción cuando reciben datos incorrectos.
Crea test_validaciones.py:
import unittest
from validaciones import (
calcular_descuento,
convertir_a_entero,
dividir,
obtener_item,
validar_nombre,
)
class TestValidaciones(unittest.TestCase):
pass
Agregaremos pruebas para resultados correctos y para errores esperados.
Antes de probar el error, conviene confirmar que la función funciona con datos válidos:
def test_dividir_dos_numeros(self):
resultado = dividir(10, 2)
self.assertEqual(resultado, 5)
Esta prueba cubre el camino normal de la función.
Para comprobar que una función lanza una excepción esperada usamos assertRaises:
def test_dividir_por_cero_lanza_error(self):
with self.assertRaises(ValueError):
dividir(10, 0)
La prueba pasa si dividir(10, 0) lanza ValueError. Si no lanza nada, o si lanza otra excepción, la prueba falla.
El bloque with self.assertRaises(...) debe contener solamente la operación que esperamos que falle:
def test_porcentaje_mayor_a_100_lanza_error(self):
with self.assertRaises(ValueError):
calcular_descuento(1000, 120)
Si el bloque incluye muchas líneas, puede ser más difícil saber cuál produjo realmente la excepción.
Una misma función puede tener varias reglas inválidas. Conviene escribir una prueba por regla:
def test_precio_negativo_lanza_error(self):
with self.assertRaises(ValueError):
calcular_descuento(-100, 10)
def test_porcentaje_negativo_lanza_error(self):
with self.assertRaises(ValueError):
calcular_descuento(1000, -5)
Separar las pruebas ayuda a identificar exactamente qué validación dejó de funcionar.
A veces el mensaje de error es parte del comportamiento que queremos garantizar. Para revisarlo, guardamos el contexto:
def test_dividir_por_cero_muestra_mensaje_claro(self):
with self.assertRaises(ValueError) as contexto:
dividir(10, 0)
self.assertEqual(str(contexto.exception), "No se puede dividir por cero")
Esto es útil cuando el mensaje será leído por otra parte del programa, por una API o por una persona usuaria.
La función validar_nombre rechaza cadenas vacías o formadas solo por espacios:
def test_nombre_vacio_lanza_error(self):
with self.assertRaises(ValueError):
validar_nombre("")
def test_nombre_con_espacios_lanza_error(self):
with self.assertRaises(ValueError):
validar_nombre(" ")
También podemos verificar el caso válido:
def test_nombre_valido_se_normaliza(self):
resultado = validar_nombre(" ana ")
self.assertEqual(resultado, "Ana")
No todas las excepciones son ValueError. Para índices inválidos podemos usar IndexError:
def test_indice_negativo_lanza_index_error(self):
with self.assertRaises(IndexError):
obtener_item(["a", "b"], -1)
def test_indice_fuera_de_rango_lanza_index_error(self):
with self.assertRaises(IndexError):
obtener_item(["a", "b"], 2)
Elegir un tipo de excepción específico hace que la prueba sea más precisa.
La función convertir_a_entero devuelve un número si el texto es válido y lanza ValueError si no lo es:
def test_convertir_texto_numerico_a_entero(self):
resultado = convertir_a_entero("42")
self.assertEqual(resultado, 42)
def test_convertir_texto_no_numerico_lanza_error(self):
with self.assertRaises(ValueError):
convertir_a_entero("abc")
Así probamos el camino exitoso y el camino de error de la misma función.
El archivo test_validaciones.py puede quedar así:
import unittest
from validaciones import (
calcular_descuento,
convertir_a_entero,
dividir,
obtener_item,
validar_nombre,
)
class TestValidaciones(unittest.TestCase):
def test_dividir_dos_numeros(self):
resultado = dividir(10, 2)
self.assertEqual(resultado, 5)
def test_dividir_por_cero_lanza_error(self):
with self.assertRaises(ValueError):
dividir(10, 0)
def test_porcentaje_mayor_a_100_lanza_error(self):
with self.assertRaises(ValueError):
calcular_descuento(1000, 120)
def test_precio_negativo_lanza_error(self):
with self.assertRaises(ValueError):
calcular_descuento(-100, 10)
def test_porcentaje_negativo_lanza_error(self):
with self.assertRaises(ValueError):
calcular_descuento(1000, -5)
def test_dividir_por_cero_muestra_mensaje_claro(self):
with self.assertRaises(ValueError) as contexto:
dividir(10, 0)
self.assertEqual(str(contexto.exception), "No se puede dividir por cero")
def test_nombre_vacio_lanza_error(self):
with self.assertRaises(ValueError):
validar_nombre("")
def test_nombre_con_espacios_lanza_error(self):
with self.assertRaises(ValueError):
validar_nombre(" ")
def test_nombre_valido_se_normaliza(self):
resultado = validar_nombre(" ana ")
self.assertEqual(resultado, "Ana")
def test_indice_negativo_lanza_index_error(self):
with self.assertRaises(IndexError):
obtener_item(["a", "b"], -1)
def test_indice_fuera_de_rango_lanza_index_error(self):
with self.assertRaises(IndexError):
obtener_item(["a", "b"], 2)
def test_convertir_texto_numerico_a_entero(self):
resultado = convertir_a_entero("42")
self.assertEqual(resultado, 42)
def test_convertir_texto_no_numerico_lanza_error(self):
with self.assertRaises(ValueError):
convertir_a_entero("abc")
if __name__ == "__main__":
unittest.main()
Desde la carpeta del proyecto, ejecuta:
python -m unittest -v
La salida esperada será similar a:
test_convertir_texto_no_numerico_lanza_error ... ok
test_convertir_texto_numerico_a_entero ... ok
test_dividir_dos_numeros ... ok
test_dividir_por_cero_lanza_error ... ok
test_dividir_por_cero_muestra_mensaje_claro ... ok
test_indice_fuera_de_rango_lanza_index_error ... ok
test_indice_negativo_lanza_index_error ... ok
test_nombre_con_espacios_lanza_error ... ok
test_nombre_valido_se_normaliza ... ok
test_nombre_vacio_lanza_error ... ok
test_porcentaje_mayor_a_100_lanza_error ... ok
test_porcentaje_negativo_lanza_error ... ok
test_precio_negativo_lanza_error ... ok
----------------------------------------------------------------------
Ran 13 tests in 0.001s
OK
Si una prueba espera una excepción y la función no la lanza, unittest muestra una falla. Por ejemplo, si dividir(10, 0) no lanzara ValueError, la prueba fallaría porque la validación no se cumplió.
Esto permite detectar cuando alguien modifica una función y elimina accidentalmente una regla de validación.
assertRaises(ValueError) espera exactamente un ValueError o una subclase compatible. Si la función lanza TypeError, IndexError u otro error, la prueba no pasa.
Por eso conviene elegir bien el tipo de excepción. Una prueba demasiado genérica puede ocultar errores reales.
Una buena suite suele probar ambos caminos:
| Camino | Qué se espera | Aserción habitual |
|---|---|---|
| Dato válido | La función devuelve un resultado | assertEqual |
| Dato inválido | La función lanza una excepción | assertRaises |
| Error con mensaje importante | La excepción contiene un texto esperado | str(contexto.exception) |
Exception puede ocultar errores inesperados.assertRaises: dificulta saber qué línea falló realmente.mkdir excepciones-demo
cd excepciones-demo
python -m unittest
python -m unittest -v
python -m unittest test_validaciones.TestValidaciones.test_dividir_por_cero_lanza_error
assertRaises verifica que una excepción ocurra.with debe contener solo la línea que esperamos que falle.as contexto.En este tema probamos funciones que validan datos y lanzan excepciones esperadas. Aprendimos a usar assertRaises, a comprobar mensajes y a separar claramente los casos válidos de los inválidos.
En el próximo tema veremos cómo organizar carpetas para separar código fuente y pruebas, una práctica importante cuando los proyectos empiezan a crecer.