pytest.raises: Cómo probar excepciones sin complicarse la vida

Probar que tu código lanza una excepción es crucial, pero la forma en que lo haces puede generar un tremendo enredo. Te explico la manera simple y efectiva con pytest.

Uff, todavía me acuerdo de una vez que me quemé feo con un bug en producción. El problema era súper específico: un servicio de terceros a veces devolvía un formato de error inesperado, y mi código, en vez de cachar la excepción y manejarla bien, simplemente fallaba ruidosamente. Yo había puesto un try...except, pero era genérico, y claro, en mis tests, creía que estaba probando esa excepción, pero en realidad estaba validando otra cosa. El test pasaba, pero no probaba lo que debía. Ahí fue cuando entendí que probar excepciones no es solo que el código falle, sino que lo haga como esperas.

Cuando trabajas con Python y Pytest, la forma más limpia y directa para asegurarte de que tu código lanza una excepción es usar pytest.raises. Punto. Olvídate de los try...except dentro de tus tests para este propósito, es sobreingeniería que solo trae problemas.

Por qué pytest.raises es la herramienta que necesitas

La idea de pytest.raises es simple: le dices qué tipo de excepción esperas y envuelves el código que debería lanzarla. Si la excepción se lanza, el test pasa. Si no se lanza, o se lanza una excepción diferente, el test falla. Eso es todo.

Mira este ejemplo básico. Imagina que tienes una función que divide dos números, y sabes que dividir por cero debería lanzar un ZeroDivisionError:


import pytest

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

def test_division_por_cero_lanza_error():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

def test_division_normal_no_lanza_error():
    assert divide(10, 2) == 5

# Esto fallaría, porque esperamos ZeroDivisionError pero se lanza TypeError
# def test_falla_por_excepcion_equivocada():
#    with pytest.raises(ZeroDivisionError):
#        divide(10, 'a') # Esto lanzaría TypeError

En el test test_division_por_cero_lanza_error, envolvemos la llamada a divide(10, 0) con with pytest.raises(ZeroDivisionError):. Pytest espera que en ese bloque se lance un ZeroDivisionError. Si ocurre, el test es exitoso. Si no, o si se lanza otra excepción (como un TypeError si pasas una string), el test falla, y Pytest te mostrará el traceback completo, que es justo lo que uno quiere para debugear.

Accediendo a la información de la excepción (as exc_info)

Muchas veces no basta con saber que se lanzó una excepción; necesitas verificar los detalles de esa excepción. Por ejemplo, el mensaje de error o los argumentos. Para eso, puedes usar as exc_info:


import pytest

def validar_edad(edad):
    if not isinstance(edad, int) or edad < 0:
        raise ValueError("La edad debe ser un número entero positivo.")
    return True

def test_validar_edad_negativa_lanza_value_error():
    with pytest.raises(ValueError) as exc_info:
        validar_edad(-5)
    
    # Ahora puedes acceder a la excepción capturada
    assert "número entero positivo" in str(exc_info.value)
    assert exc_info.type is ValueError

def test_validar_edad_string_lanza_value_error():
    with pytest.raises(ValueError) as exc_info:
        validar_edad("veinte")
    assert "número entero positivo" in str(exc_info.value)

La variable exc_info es un objeto que contiene información sobre la excepción que se ha capturado. Lo más útil es exc_info.value, que es la instancia de la excepción en sí misma. Con eso, yo puedo inspeccionar su tipo (exc_info.type) o su mensaje (str(exc_info.value)).

Esto es vital. No me sirve de nada que el test me diga "pasó" si la excepción es de otro tipo o si el mensaje es genérico. El detalle es lo que me ayuda a entender si mi manejo de errores está bien o si, por ejemplo, un servicio externo cambió su formato de error y mis validaciones quedaron cortas.

Lo que NO DEBES hacer: try...except en tus tests

Vi muchas veces, y de hecho, yo mismo lo hice al principio, a gente que prueba excepciones así:


import pytest

def lanzar_error():
    raise ValueError("Esto es un error intencional")

# NO HAGAS ESTO para probar excepciones en Pytest
def test_lanzar_error_con_try_except_malo():
    try:
        lanzar_error()
        assert False, "Se esperaba una excepción, pero no se lanzó."
    except ValueError:
        assert True # La excepción esperada fue capturada
    except Exception:
        assert False, "Se lanzó una excepción diferente a ValueError."

¿Funciona? Sí, puede que funcione. ¿Es bueno? Absolutamente no. Esto es lo que yo llamo sobreingeniería barata en los tests. Estás replicando la lógica de manejo de errores de tu aplicación dentro de tu test. Si el try falla y se lanza otra excepción, Pytest te va a dar un traceback, sí, pero si la excepción es la esperada, tu test solo dice "pasó" o "falló" de forma genérica. Pierdes la granularidad, el contexto y la claridad que pytest.raises te da gratis.

Además, esto complica la lectura. Si alguien lee tu test, tiene que entender toda la lógica de try/except/assert para saber qué estás probando. Con pytest.raises, es directo: "este código debería lanzar esta excepción".

Consideraciones adicionales

  • Especificidad de la excepción: Siempre intenta ser lo más específico posible con el tipo de excepción que esperas. Usar pytest.raises(Exception) es como pescar con red: atrapas todo, pero no sabes qué. Mejor pytest.raises(ValueError) o pytest.raises(KeyError).
  • Mensajes de error: No siempre necesitas validar el mensaje exacto. A veces, con comprobar que una parte del mensaje está presente (como hice con "número entero positivo" in str(exc_info.value)) es suficiente. Esto evita que tus tests se rompan por cambios menores en la redacción del mensaje.
  • Patrón de la expresión regular: pytest.raises también acepta un argumento match, que es una expresión regular para el mensaje de la excepción. Esto es super útil si el mensaje es dinámico o si necesitas una validación más robusta que un simple in. Por ejemplo: pytest.raises(ValueError, match=r"La edad debe ser.*positivo"). A mí me gusta mucho esto, de hecho, lo uso bastante.

Yo me demoré años en internalizar la importancia de usar las herramientas de testing de la manera más idiomática posible. Al principio, era de los que replicaba la lógica de la aplicación en el test, pensando que era más "control". Pero no, es más enredo. Ojo que no hay vergüenza en equivocarse, el tema es aprender.

Si hay algo que ojalá hubiera sabido desde el día uno, es que los tests no son solo para verificar que el código funciona, sino para documentar cómo se espera que funcione y, crucialmente, cómo se espera que falle. pytest.raises simplifica esto un montón y te ahorra muchos dolores de cabeza cuando tienes que revisar tests que otro escribió, o peor, los tuyos de hace seis meses.

Jorge RequenaDeveloper full-stack · Chile