13. Pruebas sobre excepciones y errores esperados

13.1 Introducción

No todo comportamiento correcto consiste en devolver un valor o cambiar un estado. A veces, el comportamiento correcto es rechazar una operación inválida.

Por ejemplo, dividir por cero, retirar más dinero del saldo disponible, crear un usuario sin nombre o aceptar una edad negativa pueden ser situaciones que la unidad debe impedir.

En este tema veremos cómo probar excepciones y errores esperados. La idea central es simple: una prueba también puede pasar cuando el código falla de la manera correcta y controlada.

13.2 Error inesperado y error esperado

Conviene diferenciar dos situaciones:

Tipo Significado Ejemplo
Error inesperado La unidad falla por un defecto no previsto. Una variable no inicializada produce una excepción accidental.
Error esperado La unidad rechaza una condición inválida de forma intencional. Una función lanza ValueError al dividir por cero.

Las pruebas sobre excepciones verifican el segundo caso: queremos confirmar que la unidad responde de forma controlada ante una entrada inválida.

13.3 Primer ejemplo: división por cero

Supongamos una función que divide dos números, pero rechaza el divisor cero.

def dividir(a, b):
    if b == 0:
        raise ValueError("No se puede dividir por cero")
    return a / b


def test_dividir_por_cero_lanza_error():
    try:
        dividir(10, 0)
        assert False
    except ValueError:
        assert True

La prueba pasa si se lanza ValueError. Si la función no lanza el error, la línea assert False hace fallar la prueba.

13.4 Forma expresiva con pytest

En Python, frameworks como pytest ofrecen una forma más clara de verificar excepciones.

import pytest


def test_dividir_por_cero_lanza_error():
    with pytest.raises(ValueError):
        dividir(10, 0)

Esta prueba expresa directamente la intención: esperamos que dentro del bloque se lance ValueError. Si no ocurre, la prueba falla.

En otros lenguajes existen mecanismos equivalentes, como assertThrows en JUnit o aserciones similares en frameworks de JavaScript y C#.

13.5 Qué estamos verificando

Una prueba de excepción puede verificar varias cosas:

  • Que se lanza una excepción.
  • Que la excepción es del tipo esperado.
  • Que se lanza solo bajo ciertas condiciones.
  • Que el mensaje de error comunica el problema correcto.
  • Que el estado del objeto no queda corrupto después del error.

No siempre necesitamos verificar todo. La prueba debe concentrarse en el comportamiento relevante.

13.6 Verificar el tipo de excepción

El tipo de excepción es importante porque comunica qué clase de problema ocurrió. No es lo mismo un error de valor, un error de permisos o un error de estado.

def validar_edad(edad):
    if edad < 0:
        raise ValueError("La edad no puede ser negativa")
    return True


def test_edad_negativa_lanza_value_error():
    with pytest.raises(ValueError):
        validar_edad(-1)

La prueba espera específicamente ValueError. Si la función lanzara otro tipo de excepción, la prueba debería fallar.

13.7 Verificar el mensaje de error

A veces el mensaje de error forma parte del comportamiento esperado, especialmente si será mostrado al usuario o usado para diagnosticar problemas.

def test_edad_negativa_informa_mensaje_claro():
    with pytest.raises(ValueError) as error:
        validar_edad(-1)

    assert str(error.value) == "La edad no puede ser negativa"

Esta prueba no solo verifica que se lance un error, sino también que el mensaje sea el esperado. Conviene hacerlo cuando el mensaje realmente importa.

13.8 Probar que no se lanza error en casos válidos

También debemos comprobar el caso válido. Si solo probamos entradas inválidas, no sabemos si la unidad acepta correctamente las entradas permitidas.

def test_edad_cero_es_valida():
    resultado = validar_edad(0)

    assert resultado == True

Esta prueba complementa la anterior. Una verifica el rechazo de edad negativa; la otra verifica que una edad válida no sea rechazada por error.

13.9 Ejemplo: retirar más del saldo

Veamos una clase que impide retirar más dinero del disponible.

class Cuenta:
    def __init__(self, saldo):
        self.saldo = saldo

    def retirar(self, importe):
        if importe > self.saldo:
            raise ValueError("Saldo insuficiente")
        self.saldo -= importe


def test_retirar_mas_del_saldo_lanza_error():
    cuenta = Cuenta(100)

    with pytest.raises(ValueError):
        cuenta.retirar(150)

El comportamiento correcto no es devolver un saldo negativo. El comportamiento correcto es rechazar la operación.

13.10 Verificar que el estado no cambió

Cuando una operación falla, suele ser importante comprobar que el objeto no quedó en un estado incorrecto.

def test_retirar_mas_del_saldo_no_modifica_saldo():
    cuenta = Cuenta(100)

    with pytest.raises(ValueError):
        cuenta.retirar(150)

    assert cuenta.saldo == 100

Esta prueba verifica dos aspectos relacionados: se rechaza la operación y el saldo se conserva. Es una combinación razonable porque ambas aserciones pertenecen al mismo comportamiento.

13.11 Ejemplo: datos obligatorios

Crear entidades con datos incompletos puede ser inválido. Por ejemplo, un usuario debe tener nombre.

class Usuario:
    def __init__(self, nombre):
        if nombre.strip() == "":
            raise ValueError("El nombre es obligatorio")
        self.nombre = nombre


def test_usuario_sin_nombre_lanza_error():
    with pytest.raises(ValueError):
        Usuario("")

La prueba confirma que la clase no permite crear un usuario inválido.

13.12 Evitar excepciones genéricas

Una prueba que acepta cualquier excepción puede ocultar defectos. Por ejemplo:

def test_usuario_sin_nombre_lanza_error_poco_preciso():
    with pytest.raises(Exception):
        Usuario("")

Esta prueba pasaría con ValueError, pero también con un error accidental como AttributeError. Es mejor esperar el tipo específico:

def test_usuario_sin_nombre_lanza_value_error():
    with pytest.raises(ValueError):
        Usuario("")

13.13 No ocultar errores accidentales

Una prueba mal escrita puede esconder errores reales si captura excepciones de forma demasiado amplia.

def test_malo():
    try:
        Usuario("")
    except Exception:
        assert True

Esta prueba pasa ante cualquier excepción, incluso si el código falla por un motivo inesperado. Una prueba útil debe ser específica sobre el error esperado.

13.14 Probar límites inválidos

Las excepciones suelen aparecer en límites inválidos. Por ejemplo, una cantidad debe ser mayor que cero.

def validar_cantidad(cantidad):
    if cantidad <= 0:
        raise ValueError("La cantidad debe ser mayor que cero")
    return True


def test_cantidad_cero_lanza_error():
    with pytest.raises(ValueError):
        validar_cantidad(0)


def test_cantidad_negativa_lanza_error():
    with pytest.raises(ValueError):
        validar_cantidad(-1)

Los casos cero y negativo no son duplicados sin sentido: cada uno representa una situación inválida importante.

13.15 Probar el primer valor válido

Después de probar límites inválidos, conviene probar el primer valor válido.

def test_cantidad_uno_es_valida():
    assert validar_cantidad(1) == True

Esto ayuda a confirmar que la validación no quedó demasiado estricta. Si el mínimo permitido es 1, la función debe aceptar 1.

13.16 Excepciones y contratos

Una excepción esperada forma parte del contrato de una unidad. El contrato dice no solo qué devuelve una función en casos válidos, sino también qué ocurre cuando recibe datos inválidos.

Por ejemplo, una función puede establecer este contrato:

  • Si el divisor es distinto de cero, devuelve el resultado de la división.
  • Si el divisor es cero, lanza ValueError.

Las pruebas deben cubrir ambas partes del contrato.

13.17 Cuándo devolver error y cuándo lanzar excepción

No todos los diseños usan excepciones. Algunas unidades devuelven un valor que representa error, como None, False o un objeto de resultado.

Ejemplo con retorno:

def buscar_usuario(usuarios, nombre):
    for usuario in usuarios:
        if usuario["nombre"] == nombre:
            return usuario
    return None


def test_buscar_usuario_inexistente_devuelve_none():
    usuarios = [{"nombre": "Ana"}]

    assert buscar_usuario(usuarios, "Luis") == None

Esto no es una prueba de excepción, pero sí una prueba de error esperado mediante valor de retorno. La elección depende del diseño de la unidad y del lenguaje.

13.18 Tabla de ejemplos

Situación Comportamiento esperado Prueba recomendable
Dividir por cero Lanzar ValueError Verificar excepción específica.
Edad negativa Rechazar valor inválido Verificar tipo y, si importa, mensaje.
Retiro mayor al saldo Lanzar error y conservar saldo Verificar excepción y estado final.
Usuario sin nombre Impedir creación Verificar que no se acepte el dato vacío.
Búsqueda sin resultado Devolver None Verificar valor de retorno esperado.

13.19 Lista de comprobación

Al probar excepciones y errores esperados, revisa:

  • ¿La entrada inválida está clara?
  • ¿La prueba espera el tipo de error correcto?
  • ¿No captura excepciones demasiado genéricas?
  • ¿El mensaje de error importa para este caso?
  • ¿El estado queda consistente después del error?
  • ¿También existe una prueba para el caso válido cercano?

13.20 Qué debes recordar de este tema

  • Una prueba puede pasar cuando se lanza una excepción esperada.
  • Un error esperado es distinto de un error accidental.
  • Conviene verificar excepciones específicas, no cualquier excepción.
  • El mensaje de error se prueba solo cuando forma parte del comportamiento esperado.
  • Después de un error, puede ser importante verificar que el estado no cambió.
  • También deben probarse casos válidos para evitar validaciones demasiado estrictas.
  • Algunas unidades devuelven valores de error en lugar de lanzar excepciones.

13.21 Conclusión

Las excepciones y errores esperados son parte del comportamiento de una unidad. Una prueba unitaria no solo debe confirmar que el código funciona con datos válidos; también debe verificar que rechaza correctamente los datos inválidos.

La clave es ser específico: qué condición inválida se prepara, qué error se espera y qué estado debe conservarse después de la falla.

En el próximo tema veremos pruebas positivas, negativas y casos borde, una forma práctica de organizar los distintos tipos de situaciones que debemos cubrir.