8. Probar funciones con errores y excepciones esperadas

8.1 Objetivo del tema

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.

Idea clave: un error esperado también forma parte del comportamiento del programa y debe probarse.

8.2 Crear una carpeta de práctica

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.

8.3 Crear funciones con validaciones

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.

8.4 Crear el archivo de pruebas

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.

8.5 Probar primero el caso válido

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.

8.6 Probar una excepción con assertRaises

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.

8.7 Encerrar solo la línea que debe fallar

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.

8.8 Probar distintas validaciones de una misma funció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.

8.9 Probar el mensaje de la excepción

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.

8.10 Probar cadenas inválidas

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")

8.11 Probar otro tipo de excepción

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.

8.12 Probar conversión de datos

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.

8.13 Archivo completo de pruebas

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()

8.14 Ejecutar las pruebas

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

8.15 Qué ocurre si no se lanza la excepción

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.

8.16 Qué ocurre si se lanza otra excepció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.

8.17 Comparar caso válido y caso inválido

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)

8.18 Errores frecuentes

  • Usar una excepción demasiado general: esperar Exception puede ocultar errores inesperados.
  • Encerrar muchas líneas dentro de assertRaises: dificulta saber qué línea falló realmente.
  • No probar el caso válido: una función puede lanzar bien el error, pero calcular mal el resultado normal.
  • Probar mensajes que cambian sin importancia: conviene verificar el mensaje solo si realmente forma parte del contrato.
  • Confundir falla con error: una falla indica que la aserción no se cumplió; un error indica una excepción no esperada.

8.19 Comandos usados en este tema

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

8.20 Qué debes recordar de este tema

  • Los errores esperados también deben probarse.
  • assertRaises verifica que una excepción ocurra.
  • El bloque with debe contener solo la línea que esperamos que falle.
  • Podemos comprobar el mensaje usando as contexto.
  • Conviene probar tanto datos válidos como datos inválidos.
  • El tipo de excepción debe ser lo más específico posible.

8.21 Conclusión

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.