Guía completa de pruebas de excepciones y errores con pytest en Python

En el desarrollo de software, las pruebas de manejo de excepciones y errores son extremadamente importantes. Realizar pruebas adecuadas puede mejorar la fiabilidad del código y prevenir fallos debidos a errores inesperados. En este artículo, explicaremos detalladamente cómo probar de manera eficiente el manejo de excepciones y errores utilizando pytest, un marco de pruebas en Python. Desde la configuración básica hasta las pruebas de excepciones personalizadas y el manejo de múltiples excepciones, lo explicaremos paso a paso.

Índice

Configuración básica de pytest

pytest es un marco de pruebas potente para Python que se puede instalar y usar fácilmente. Sigamos los siguientes pasos para configurar pytest.

Instalación de pytest

Primero, necesitas instalar pytest. Utiliza el siguiente comando para instalarlo a través de pip.

pip install pytest

Estructura básica del directorio

Se recomienda la siguiente estructura de directorios para el proyecto de pruebas.

my_project/
├── src/
│   └── my_module.py
└── tests/
    ├── __init__.py
    └── test_my_module.py

Creación del primer archivo de prueba

A continuación, crea un archivo de prueba de Python. Por ejemplo, crea un archivo llamado test_my_module.py en el directorio tests y escribe el siguiente contenido.

def test_example():
    assert 1 + 1 == 2

Ejecución de las pruebas

Para ejecutar las pruebas, ejecuta el siguiente comando en el directorio raíz del proyecto.

pytest

Esto hará que pytest detecte automáticamente los archivos de prueba en el directorio tests y los ejecute. Si los resultados de las pruebas son satisfactorios, la configuración básica estará completa.

Cómo probar excepciones

Para asegurarse de que las excepciones se lanzan correctamente, pytest ofrece una forma muy conveniente. Aquí explicaremos cómo probar que se lance una excepción específica.

Pruebas de excepciones con pytest.raises

Para verificar que se lanza una excepción, utiliza el administrador de contexto pytest.raises. En el siguiente ejemplo, probaremos que se lance una excepción de división por cero.

import pytest

def test_zero_division():
    with pytest.raises(ZeroDivisionError):
        1 / 0

En esta prueba, verificamos que al ejecutar 1 / 0 se lance un ZeroDivisionError.

Verificación del mensaje de la excepción

No solo puedes verificar que ocurra una excepción, sino también que contenga un mensaje de error específico. Para ello, usa el parámetro match.

def test_zero_division_message():
    with pytest.raises(ZeroDivisionError, match="division by zero"):
        1 / 0

En esta prueba, verificamos que se lance un ZeroDivisionError y que el mensaje de error contenga “division by zero”.

Probar múltiples excepciones

También es posible probar múltiples excepciones en un solo caso de prueba, por ejemplo, para verificar que se lancen excepciones diferentes bajo diferentes condiciones.

def test_multiple_exceptions():
    with pytest.raises(ZeroDivisionError):
        1 / 0

    with pytest.raises(TypeError):
        '1' + 1

En esta prueba, primero verificamos que se lance un ZeroDivisionError y luego verificamos que se lance un TypeError.

Realizar pruebas adecuadas de excepciones garantiza que el código pueda manejar errores de manera correcta sin comportamientos inesperados.

Verificación de mensajes de error

Es importante verificar que se incluya un mensaje de error específico en una prueba. A continuación, explicaremos cómo validar mensajes de error con pytest.

Verificar un mensaje de error específico

Puedes probar no solo que ocurra una excepción específica, sino también que el mensaje de la excepción contenga un texto específico. Utiliza pytest.raises con el parámetro match para especificar el mensaje de error.

import pytest

def test_value_error_message():
    def raise_value_error():
        raise ValueError("This is a ValueError with a specific message.")

    with pytest.raises(ValueError, match="specific message"):
        raise_value_error()

En esta prueba, verificamos que se lance un ValueError y que el mensaje contenga “specific message”.

Validación del mensaje de error con expresiones regulares

Cuando el mensaje de error se genera dinámicamente o deseas verificar una coincidencia parcial, puedes usar expresiones regulares.

def test_regex_error_message():
    def raise_type_error():
        raise TypeError("TypeError: invalid type for operation")

    with pytest.raises(TypeError, match=r"invalid type"):
        raise_type_error()

En esta prueba, verificamos que el mensaje de TypeError contenga la frase “invalid type”. Al pasar una expresión regular al parámetro match, podemos verificar coincidencias parciales.

Verificación de mensajes de error personalizados

También puedes verificar mensajes de error en excepciones personalizadas de la misma manera.

class CustomError(Exception):
    pass

def test_custom_error_message():
    def raise_custom_error():
        raise CustomError("This is a custom error message.")

    with pytest.raises(CustomError, match="custom error message"):
        raise_custom_error()

En esta prueba, verificamos que se lance un CustomError y que el mensaje contenga “custom error message”.

La verificación de mensajes de error es esencial para garantizar la consistencia de los mensajes de error para los usuarios y la precisión de la información de depuración. Utiliza pytest para realizar estas pruebas de manera eficiente.

Pruebas de manejo de múltiples excepciones

Si una función puede lanzar múltiples excepciones, es importante probar si cada excepción se maneja correctamente. A continuación, mostraremos cómo probar diferentes manejos de excepciones con pytest.

Verificar múltiples excepciones en una prueba

Cuando una función lanza diferentes excepciones bajo distintas condiciones de entrada, realiza pruebas para asegurarte de que cada excepción se maneje adecuadamente.

import pytest

def error_prone_function(value):
    if value == 0:
        raise ValueError("Value cannot be zero")
    elif value < 0:
        raise TypeError("Value cannot be negative")
    return True

def test_multiple_exceptions():
    with pytest.raises(ValueError, match="Value cannot be zero"):
        error_prone_function(0)

    with pytest.raises(TypeError, match="Value cannot be negative"):
        error_prone_function(-1)

En esta prueba, verificamos que error_prone_function lance un ValueError cuando el valor es 0, y un TypeError cuando es un valor negativo.

Pruebas parametrizadas para verificar excepciones

Usa pruebas parametrizadas para probar de manera eficiente que ocurran diferentes excepciones en la misma función.

@pytest.mark.parametrize("value, expected_exception, match_text", [
    (0, ValueError, "Value cannot be zero"),
    (-1, TypeError, "Value cannot be negative")
])
def test_error_prone_function(value, expected_exception, match_text):
    with pytest.raises(expected_exception, match=match_text):
        error_prone_function(value)

En esta prueba parametrizada, combinamos diferentes valores de value con la excepción esperada y el mensaje de error para realizar las pruebas.

Probar múltiples excepciones con un método personalizado

También puedes probar funciones que lanzan múltiples excepciones utilizando un método personalizado para hacerlo de manera eficiente.

def test_custom_multiple_exceptions():
    def assert_raises_with_message(func, exception, match_text):
        with pytest.raises(exception, match=match_text):
            func()

    assert_raises_with_message(lambda: error_prone_function(0), ValueError, "Value cannot be zero")
    assert_raises_with_message(lambda: error_prone_function(-1), TypeError, "Value cannot be negative")

En esta prueba, usamos el método personalizado assert_raises_with_message para verificar que la función lanza la excepción y el mensaje de error correctos para una entrada específica.

Agrupar la verificación de múltiples excepciones en una sola prueba ayuda a reducir la redundancia del código de prueba y mejora su mantenibilidad. Aprovecha las funcionalidades de pytest para realizar pruebas de manejo de excepciones de manera eficiente.

Pruebas de excepciones personalizadas

Definir y utilizar excepciones personalizadas permite aclarar el manejo de errores en una aplicación, facilitando la respuesta adecuada a situaciones de error específicas. A continuación, explicamos cómo probar excepciones personalizadas.

Definir excepciones personalizadas

Primero, define una excepción personalizada. Hereda de una clase de excepción incorporada en Python para crear una nueva clase de excepción.

class CustomError(Exception):
    """Clase base para excepciones personalizadas"""
    pass

class SpecificError(CustomError):
    """Excepción personalizada que representa un error específico"""
    pass

Función que lanza excepciones personalizadas

A continuación, crea una función que lance una excepción personalizada bajo ciertas condiciones.

def function_that_raises(value):
    if value == 'error':
        raise SpecificError("An error occurred with value: error")
    return True

Prueba de excepciones personalizadas

Utiliza pytest para probar que las excepciones personalizadas se lanzan correctamente.

import pytest

def test_specific_error():
    with pytest.raises(SpecificError, match="An error occurred with value: error"):
        function_that_raises('error')

En esta prueba, verificamos que la función function_that_raises lance un SpecificError y que el mensaje de error sea el esperado.

Prueba de múltiples excepciones personalizadas

Cuando se utilizan múltiples excepciones personalizadas, es importante probar que cada una se maneje adecuadamente.

class AnotherCustomError(Exception):
    """Otra excepción personalizada"""
    pass

def function_with_multiple_custom_errors(value):
    if value == 'first':
        raise SpecificError("First error occurred")
    elif value == 'second':
        raise AnotherCustomError("Second error occurred")
    return True

def test_multiple_custom_errors():
    with pytest.raises(SpecificError, match="First error occurred"):
        function_with_multiple_custom_errors('first')

    with pytest.raises(AnotherCustomError, match="Second error occurred"):
        function_with_multiple_custom_errors('second')

En esta prueba, verificamos que la función function_with_multiple_custom_errors lance la excepción personalizada correcta según el valor de entrada.

Verificación del mensaje de una excepción personalizada

También es importante verificar si el mensaje de la excepción personalizada es el correcto.

def test_custom_error_message():
    with pytest.raises(SpecificError, match="An error occurred with value: error"):
        function_that_raises('error')

En esta prueba, verificamos que se lance un SpecificError y que el mensaje de error contenga “An error occurred with value: error”.

A través de la prueba de excepciones personalizadas, puedes asegurarte de que la aplicación maneje adecuadamente situaciones de error específicas, mejorando la calidad y fiabilidad del código.

Caso práctico: Pruebas de errores en una API

Al desarrollar una API, el manejo de errores es crucial. Para garantizar que el cliente reciba mensajes de error adecuados, es necesario realizar pruebas de errores. Aquí presentamos cómo probar el manejo de errores en una API utilizando pytest.

Ejemplo de API con FastAPI

Primero, definimos un punto final sencillo de FastAPI. En este ejemplo, creamos un punto final que genera un error específico.

from fastapi import FastAPI, HTTPException

app = FastAPI()

@app.get("/items/{item_id}")
async def read_item(item_id: int):
    if item_id == 0:
        raise HTTPException(status_code=400, detail="Item ID cannot be zero")
    if item_id < 0:
        raise HTTPException(status_code=404, detail="Item not found")
    return {"item_id": item_id, "name": "Item Name"}

Este punto final devuelve un error 400 si el item_id es 0, y un error 404 si el valor es negativo.

Pruebas de errores con pytest y httpx

A continuación, realizamos pruebas de errores en la API utilizando pytest y httpx.

import pytest
from httpx import AsyncClient
from main import app

@pytest.mark.asyncio
async def test_read_item_zero():
    async with AsyncClient(app=app, base_url="http://test") as ac:
        response = await ac.get("/items/0")
    assert response.status_code == 400
    assert response.json() == {"detail": "Item ID cannot be zero"}

@pytest.mark.asyncio
async def test_read_item_negative():
    async with AsyncClient(app=app, base_url="http://test") as ac:
        response = await ac.get("/items/-1")
    assert response.status_code == 404
    assert response.json() == {"detail": "Item not found"}

En esta prueba, enviamos una solicitud a /items/0 y verificamos que se devuelva un error 400 con un mensaje de error específico. También enviamos una solicitud a /items/-1 para verificar que se devuelva un error 404 y un mensaje de error.

Automatización y optimización de pruebas de errores

Puedes usar pruebas parametrizadas para combinar diferentes casos de error en una sola prueba.

@pytest.mark.asyncio
@pytest.mark.parametrize("item_id, expected_status, expected_detail", [
    (0, 400, "Item ID cannot be zero"),
    (-1, 404, "Item not found"),
])
async def test_read_item_errors(item_id, expected_status, expected_detail):
    async with AsyncClient(app=app, base_url="http://test") as ac:
        response = await ac.get(f"/items/{item_id}")
    assert response.status_code == expected_status
    assert response.json() == {"detail": expected_detail}

En esta prueba parametrizada, verificamos el código de estado esperado y el mensaje de error para diferentes valores de item_id, manteniendo el código de prueba limpio y eficiente.

Realizar pruebas de errores en la API asegura que el cliente reciba mensajes de error precisos, mejorando la fiabilidad de la aplicación.

Pruebas de errores con pytest utilizando fixtures

Los fixtures son una poderosa funcionalidad de pytest que permite configurar y limpiar eficientemente las pruebas. Utilizar fixtures para pruebas de errores puede mejorar la reutilización y la legibilidad del código.

Conceptos básicos de los fixtures

Primero, expliquemos el uso básico de los fixtures en pytest. Los fixtures permiten definir configuraciones comunes para ser utilizadas en múltiples pruebas.

import pytest

@pytest.fixture
def sample_data():
    return {"name": "test", "value": 42}

def test_sample_data(sample_data):
    assert sample_data["name"] == "test"
    assert sample_data["value"] == 42

En este ejemplo, definimos un fixture llamado sample_data y lo usamos en una función de prueba.

Uso de fixtures para configurar la API

Para pruebas de API, puedes usar un fixture para configurar el cliente. El siguiente ejemplo muestra cómo configurar un AsyncClient de httpx como fixture.

import pytest
from httpx import AsyncClient
from main import app

@pytest.fixture
async def async_client():
    async with AsyncClient(app=app, base_url="http://test") as ac:
        yield ac

@pytest.mark.asyncio
async def test_read_item_zero(async_client):
    response = await async_client.get("/items/0")
    assert response.status_code == 400
    assert response.json() == {"detail": "Item ID cannot be zero"}

En esta prueba, utilizamos el fixture async_client para configurar el cliente y enviar solicitudes a la API.

Uso de fixtures en pruebas de múltiples errores

Al usar fixtures, puedes realizar pruebas de múltiples errores de manera eficiente.

@pytest.mark.asyncio
@pytest.mark.parametrize("item_id, expected_status, expected_detail", [
    (0, 400, "Item ID cannot be zero"),
    (-1, 404, "Item not found"),
])
async def test_read_item_errors(async_client, item_id, expected_status, expected_detail):
    response = await async_client.get(f"/items/{item_id}")
    assert response.status_code == expected_status
    assert response.json() == {"detail": expected_detail}

En este ejemplo, combinamos el fixture async_client con pruebas parametrizadas para escribir de manera concisa los diferentes casos de error.

Fixture para la conexión a la base de datos

En pruebas que requieren una conexión a la base de datos, puedes usar un fixture para configurar y limpiar la conexión.

import sqlalchemy
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker

DATABASE_URL = "sqlite+aiosqlite:///./test.db"

@pytest.fixture
async def async_db_session():
    engine = create_async_engine(DATABASE_URL, echo=True)
    async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
    async with async_session() as session:
        yield session
    await engine.dispose()

async def test_db_interaction(async_db_session):
    result = await async_db_session.execute("SELECT 1")
    assert result.scalar() == 1

En este ejemplo, utilizamos el fixture async_db_session para gestionar la conexión a la base de datos y realizar la limpieza posterior a la prueba.

El uso de fixtures ayuda a reducir la duplicación de código en las pruebas y facilita el mantenimiento del código. Aprovecha los fixtures de pytest para implementar pruebas de errores de manera eficiente.

Conclusión

Las pruebas de excepciones y errores con pytest son esenciales para mejorar la calidad del software. En este artículo, hemos explicado desde la configuración básica hasta cómo probar excepciones, verificar mensajes de error, manejar múltiples excepciones, probar excepciones personalizadas, realizar pruebas de errores en una API y usar fixtures en las pruebas.

Al aprovechar al máximo las funcionalidades de pytest, puedes realizar pruebas de manejo de errores de manera eficiente y efectiva. Esto mejora la fiabilidad y el mantenimiento del código, previniendo problemas causados por errores inesperados. Continúa aprendiendo sobre las técnicas de pruebas con pytest para desarrollar software de mayor calidad.

Índice