Guida completa ai test di eccezioni ed errori con pytest in Python

Nel contesto dello sviluppo software, il test della gestione delle eccezioni e degli errori è di fondamentale importanza. Effettuare test adeguati consente di migliorare l’affidabilità del codice e prevenire guasti causati da errori imprevisti. In questo articolo, spiegheremo nel dettaglio come testare efficacemente le eccezioni e la gestione degli errori utilizzando pytest, un framework di test per Python. Illustreremo passo dopo passo come eseguire le impostazioni di base, testare eccezioni personalizzate e gestire più eccezioni.

Indice

Impostazione di base di pytest

pytest è un potente framework di test per Python, facile da installare e utilizzare. Segui questi passaggi per configurare pytest.

Installazione di pytest

Per prima cosa, è necessario installare pytest. Usa il seguente comando per installarlo tramite pip.

pip install pytest

Struttura di base delle directory

Si consiglia di organizzare le directory dei test del progetto nel modo seguente.

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

Creazione del primo file di test

Successivamente, crea un file Python per i test. Ad esempio, crea un file chiamato test_my_module.py nella directory tests e inserisci il seguente contenuto.

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

Esecuzione dei test

Per eseguire i test, esegui il seguente comando nella directory principale del progetto.

pytest

Questo comando farà sì che pytest rilevi e esegua automaticamente i file di test nella directory tests. Se i risultati del test mostrano successo, l’impostazione di base è completa.

Come testare le eccezioni

Per verificare che le eccezioni vengano sollevate correttamente, pytest offre un metodo molto utile. Qui spiegheremo come testare il verificarsi di specifiche eccezioni.

Test delle eccezioni con pytest.raises

Per verificare che un’eccezione venga sollevata, utilizza il gestore di contesto pytest.raises. Nell’esempio seguente, testeremo il verificarsi di una divisione per zero.

import pytest

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

Questo test verifica che venga sollevata un’eccezione ZeroDivisionError quando viene eseguito 1 / 0.

Verifica del messaggio di eccezione

Oltre a verificare che un’eccezione venga sollevata, potresti voler controllare che un messaggio di errore specifico sia presente. In questo caso, utilizza il parametro match.

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

Questo test verifica che venga sollevata un’eccezione ZeroDivisionError e che il messaggio di errore contenga “division by zero”.

Test di più eccezioni

È possibile testare più eccezioni in un singolo caso di test, ad esempio, per verificare che si sollevino eccezioni diverse in condizioni differenti.

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

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

Questo test verifica prima che venga sollevata un’eccezione ZeroDivisionError e poi che venga sollevata un’eccezione TypeError.

Eseguire correttamente i test delle eccezioni consente di garantire che il codice gestisca gli errori in modo corretto senza comportamenti imprevisti.

Verifica dei messaggi di errore

È importante nei test verificare che un messaggio di errore specifico sia presente. Vediamo come validare i messaggi di errore utilizzando pytest.

Verifica di un messaggio di errore specifico

È possibile testare non solo che venga sollevata una specifica eccezione, ma anche che questa contenga un determinato messaggio di errore. Utilizza pytest.raises e specifica il parametro match per il messaggio di errore.

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

Questo test verifica che venga sollevata un’eccezione ValueError e che il messaggio contenga “specific message”.

Verifica dei messaggi di errore con espressioni regolari

Se i messaggi di errore vengono generati dinamicamente o vuoi verificare una corrispondenza parziale, puoi utilizzare le espressioni regolari.

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

Questo test verifica che il messaggio dell’eccezione TypeError contenga la frase “invalid type”. Passando un’espressione regolare al parametro match, è possibile verificare una corrispondenza parziale.

Verifica dei messaggi di errore personalizzati

Puoi utilizzare lo stesso metodo per verificare i messaggi di errore per eccezioni personalizzate definite dall’utente.

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

Questo test verifica che venga sollevata un’eccezione CustomError e che il suo messaggio contenga “custom error message”.

La verifica dei messaggi di errore è importante per garantire la coerenza dei messaggi mostrati agli utenti e l’accuratezza delle informazioni di debug. Sfrutta pytest per eseguire questi test in modo efficiente.

Test di gestione di più eccezioni

Quando una funzione può sollevare più eccezioni, è importante testare che ciascuna eccezione venga gestita correttamente. Vediamo come testare più gestioni di eccezioni con pytest.

Verifica di più eccezioni in un singolo test

Quando una funzione solleva eccezioni diverse in base a input differenti, è possibile verificare che ogni eccezione venga gestita correttamente eseguendo test multipli.

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)

Questo test verifica che la funzione error_prone_function sollevi un'eccezione ValueError quando il valore è 0 e un'eccezione TypeError quando il valore è negativo.

Verifica delle eccezioni con test parametrizzati

Puoi testare in modo efficiente il sollevamento di eccezioni diverse utilizzando i test parametrizzati.

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

Questo test parametrizzato verifica che il valore di value sollevi l'eccezione prevista con il messaggio di errore corrispondente.

Test delle eccezioni con metodi personalizzati

Puoi testare in modo efficiente una funzione che solleva più eccezioni utilizzando un metodo personalizzato.

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

Questo test utilizza il metodo personalizzato assert_raises_with_message per verificare che la funzione sollevi l'eccezione corretta con il messaggio appropriato per i vari input.

Verificare più eccezioni in un singolo test consente di ridurre la duplicazione del codice di test e migliorare la manutenzione. Sfrutta le funzionalità di pytest per testare in modo efficiente la gestione delle eccezioni.

Test delle eccezioni personalizzate

Definire e utilizzare eccezioni personalizzate rende la gestione degli errori nell'applicazione più chiara e consente una risposta adeguata a situazioni di errore specifiche. Vediamo come testare le eccezioni personalizzate.

Definizione di eccezioni personalizzate

Per prima cosa, definiamo un'eccezione personalizzata. Crea una nuova classe di eccezione ereditando da una classe di eccezione integrata di Python.

class CustomError(Exception):
    """Classe base per eccezioni personalizzate"""
    pass

class SpecificError(CustomError):
    """Eccezione personalizzata per un errore specifico"""
    pass

Funzione che solleva un'eccezione personalizzata

Successivamente, creiamo una funzione che solleva un'eccezione personalizzata in determinate condizioni.

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

Test delle eccezioni personalizzate

Utilizziamo pytest per verificare che le eccezioni personalizzate vengano sollevate correttamente.

import pytest

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

Questo test verifica che la funzione function_that_raises sollevi un'eccezione SpecificError con il messaggio di errore previsto.

Test di più eccezioni personalizzate

Se si utilizzano più eccezioni personalizzate, è possibile testare che ciascuna venga gestita correttamente.

class AnotherCustomError(Exception):
    """Altra eccezione personalizzata"""
    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')

Questo test verifica che la funzione function_with_multiple_custom_errors sollevi l'eccezione personalizzata corretta per i diversi valori di input.

Verifica dei messaggi delle eccezioni personalizzate

È importante verificare anche la correttezza del messaggio delle eccezioni personalizzate.

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

Questo test verifica che venga sollevata un'eccezione SpecificError e che il messaggio di errore contenga "An error occurred with value: error".

Testare le eccezioni personalizzate aiuta a garantire che l'applicazione gestisca correttamente situazioni di errore specifiche, migliorando la qualità e l'affidabilità del codice.

Esempio applicativo: test degli errori in un'API

Quando si sviluppa un'API, la gestione degli errori è cruciale. Per garantire che il client riceva correttamente i messaggi di errore, è necessario eseguire i test degli errori. Qui vedremo come testare la gestione degli errori in un'API utilizzando pytest.

Esempio di API con FastAPI

Per prima cosa, definiamo un semplice endpoint FastAPI che genera un errore in base a condizioni specifiche.

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

Questo endpoint restituisce un errore 400 se l'item_id è 0 e un errore 404 se è negativo.

Test degli errori con pytest e httpx

Utilizzeremo pytest e httpx per eseguire i test sugli errori dell'API.

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

In questo test, inviamo una richiesta a /items/0 e verifichiamo che venga restituito un errore 400 con il messaggio previsto. Facciamo lo stesso per /items/-1, controllando che venga restituito un errore 404.

Automatizzazione ed efficienza dei test degli errori

È possibile automatizzare e rendere più efficienti i test degli errori utilizzando i test parametrizzati.

@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}

In questo test parametrizzato, verifichiamo che per diversi item_id venga restituito il codice di stato e il messaggio di errore previsti. Ciò rende il codice del test più conciso ed efficiente.

Eseguire i test sugli errori dell'API garantisce che i client ricevano i messaggi di errore corretti e migliora l'affidabilità dell'applicazione.

Test degli errori con l'uso delle fixture di pytest

Le fixture sono una potente funzionalità di pytest che consente di gestire in modo efficiente la configurazione e la pulizia dei test. Utilizzando le fixture nei test degli errori, è possibile migliorare la riutilizzabilità e la leggibilità del codice.

Concetti base delle fixture

Iniziamo spiegando l'utilizzo base delle fixture di pytest. Le fixture consentono di definire operazioni comuni di preparazione da utilizzare in più test.

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

In questo esempio, definiamo una fixture chiamata sample_data e la utilizziamo nella funzione di test.

Uso delle fixture per la configurazione dell'API

Nei test delle API, è possibile utilizzare una fixture per configurare il client. Nell'esempio seguente, configuriamo AsyncClient di httpx come 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"}

In questo test, utilizziamo la fixture async_client per configurare il client e inviare richieste all'API.

Utilizzo delle fixture per testare più errori

L'uso delle fixture consente di gestire in modo efficiente più test di errori.

@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}

In questo esempio, combiniamo la fixture async_client con i test parametrizzati per scrivere in modo conciso i casi di test per diversi errori.

Fixture per la connessione al database

Nei test che richiedono una connessione al database, le fixture possono gestire la configurazione e la pulizia della connessione.

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

In questo esempio, utilizziamo la fixture async_db_session per gestire la connessione al database e la pulizia dopo il test.

Utilizzare le fixture aiuta a ridurre la duplicazione del codice nei test e semplifica la manutenzione dei test stessi. Sfrutta le fixture di pytest per realizzare test degli errori efficienti.

Conclusione

I test delle eccezioni e degli errori con pytest sono essenziali per migliorare la qualità del software. In questo articolo, abbiamo coperto l'impostazione di base, i metodi per testare le eccezioni, la verifica dei messaggi di errore, il test di più eccezioni, il test delle eccezioni personalizzate, i test degli errori nell'API e l'uso delle fixture per migliorare i test.

Sfruttando al massimo le funzionalità di pytest, è possibile eseguire test degli errori in modo efficiente ed efficace. Ciò migliora l'affidabilità e la manutenibilità del codice, prevenendo problemi causati da errori imprevisti. Continua a imparare le tecniche di test con pytest per sviluppare software di alta qualità.

Indice