Python: test e debugging delle eccezioni personalizzate con unittest e pytest

by theArchitect
SHARE
Python: test e debugging delle eccezioni personalizzate con unittest e pytest
© Guida-HTML5.it

Introduzione

Quando si scrivono test in Python, spesso ci si concentra sul “caso felice”: input valido e output corretto. Nella pratica, però, gran parte della robustezza di un’applicazione dipende dalla gestione degli errori. Un sotto-argomento molto utile, pratico e spesso sottovalutato è il testing delle eccezioni personalizzate: verificare che il codice sollevi l’errore giusto, con il messaggio giusto e nel momento giusto.

Questo tema è perfetto per imparare a usare bene unittest e pytest, perché entrambi offrono strumenti chiari per controllare il comportamento in caso di fallimento. Inoltre, durante il debugging, test ben scritti sulle eccezioni aiutano a capire rapidamente dove il flusso di esecuzione si interrompe e perché.

In questo tutorial vedremo un esempio realistico: una funzione che valida un importo monetario e una classe che gestisce un pagamento. Se il valore non è valido, il codice solleva eccezioni personalizzate. Poi testeremo tutto con unittest e pytest, confrontando approcci e vantaggi.

Codice completo

# file: payment.py

class InvalidAmountError(ValueError):
    """Eccezione sollevata quando un importo non è valido."""
    pass


class PaymentError(RuntimeError):
    """Eccezione sollevata quando il pagamento non può essere completato."""
    pass


def validate_amount(amount):
    """
    Verifica che amount sia un numero positivo.
    Solleva InvalidAmountError in caso contrario.
    """
    if not isinstance(amount, (int, float)):
        raise InvalidAmountError("L´importo deve essere numerico")

    if amount <= 0:
        raise InvalidAmountError("L´importo deve essere maggiore di zero")

    return round(amount, 2)


class PaymentProcessor:
    def __init__(self, balance):
        self.balance = balance

    def pay(self, amount):
        """
        Esegue un pagamento se il saldo è sufficiente.
        Solleva PaymentError se il saldo non basta.
        """
        amount = validate_amount(amount)

        if amount > self.balance:
            raise PaymentError(
                f"Saldo insufficiente: saldo={self.balance}, importo={amount}"
            )

        self.balance -= amount
        return self.balance


# file: test_payment_unittest.py

import unittest
from payment import validate_amount, PaymentProcessor, InvalidAmountError, PaymentError


class TestValidateAmount(unittest.TestCase):
    def test_valid_amount(self):
        self.assertEqual(validate_amount(10), 10)

    def test_valid_float_amount(self):
        self.assertEqual(validate_amount(10.456), 10.46)

    def test_non_numeric_amount_raises(self):
        with self.assertRaises(InvalidAmountError) as context:
            validate_amount("10")

        self.assertEqual(str(context.exception), "L´importo deve essere numerico")

    def test_negative_amount_raises(self):
        with self.assertRaises(InvalidAmountError) as context:
            validate_amount(-5)

        self.assertEqual(str(context.exception), "L´importo deve essere maggiore di zero")


class TestPaymentProcessor(unittest.TestCase):
    def test_payment_reduces_balance(self):
        processor = PaymentProcessor(balance=100)
        new_balance = processor.pay(25)

        self.assertEqual(new_balance, 75)
        self.assertEqual(processor.balance, 75)

    def test_payment_insufficient_balance_raises(self):
        processor = PaymentProcessor(balance=50)

        with self.assertRaises(PaymentError) as context:
            processor.pay(100)

        self.assertIn("Saldo insufficiente", str(context.exception))

    def test_invalid_payment_amount_propagates_error(self):
        processor = PaymentProcessor(balance=100)

        with self.assertRaises(InvalidAmountError):
            processor.pay(0)


# file: test_payment_pytest.py

import pytest
from payment import validate_amount, PaymentProcessor, InvalidAmountError, PaymentError


def test_validate_amount_ok():
    assert validate_amount(15) == 15


def test_validate_amount_raises_for_string():
    with pytest.raises(InvalidAmountError, match="deve essere numerico"):
        validate_amount("abc")


def test_validate_amount_raises_for_zero():
    with pytest.raises(InvalidAmountError, match="maggiore di zero"):
        validate_amount(0)


def test_payment_success():
    processor = PaymentProcessor(balance=80)
    assert processor.pay(30) == 50
    assert processor.balance == 50


def test_payment_insufficient_balance():
    processor = PaymentProcessor(balance=20)

    with pytest.raises(PaymentError, match="Saldo insufficiente"):
        processor.pay(25)

Spiegazione

Il codice è diviso in due parti: la logica applicativa e i test. Questo è un punto fondamentale nella progettazione dei test: il codice di produzione deve essere semplice da testare, e i test devono verificare comportamenti osservabili, non dettagli interni inutili.

1. Eccezioni personalizzate

Abbiamo definito due eccezioni:

  • InvalidAmountError, che segnala un problema con il valore passato in input.
  • PaymentError, che segnala un problema di dominio, cioè un pagamento non eseguibile per saldo insufficiente.

Separare gli errori in classi diverse è una buona pratica perché rende i test più precisi e il codice più leggibile. Nei test possiamo verificare non solo che “qualcosa fallisce”, ma quale tipo di errore viene sollevato.

2. Testing con unittest

In unittest il costrutto più importante per le eccezioni è assertRaises. Si usa come context manager:

  • entra nel blocco con with self.assertRaises(...)
  • esegue il codice che dovrebbe fallire
  • cattura l’eccezione e permette di ispezionarla

Nel test possiamo anche controllare il messaggio d’errore con str(context.exception). Questo è utile quando il messaggio è parte del contratto dell’API o quando vogliamo assicurarci che il problema sia spiegato bene all’utente.

3. Testing con pytest

Con pytest l’approccio è più compatto. La funzione pytest.raises permette di verificare il tipo di eccezione e, opzionalmente, il messaggio con il parametro match.

Per esempio:

  • with pytest.raises(InvalidAmountError, match="deve essere numerico")
  • with pytest.raises(PaymentError, match="Saldo insufficiente")

Il vantaggio è la leggibilità: nei test brevi e focalizzati, pytest risulta spesso più espressivo.

4. Debugging guidato dai test

Quando un test sulle eccezioni fallisce, il messaggio di errore aiuta a capire subito il problema. Per esempio:

  • se il test si aspetta InvalidAmountError ma il codice solleva TypeError, significa che la validazione è incompleta;
  • se il messaggio non corrisponde, probabilmente il comportamento è corretto ma la comunicazione all’utente è incoerente;
  • se il pagamento modifica il saldo anche quando dovrebbe fallire, il bug è nel flusso di controllo.

In fase di debugging, questi test diventano una rete di sicurezza: permettono di localizzare il problema prima che arrivi in produzione.

Best practice

  • Usa eccezioni personalizzate per errori di dominio: migliorano la chiarezza e rendono i test più mirati.
  • Testa sia il tipo di eccezione sia il messaggio quando il messaggio fa parte del comportamento atteso.
  • Evita test troppo generici: controllare solo che “si alzi un’eccezione” può nascondere bug logici.
  • Scrivi test piccoli e indipendenti: ogni test dovrebbe verificare un solo comportamento.
  • Non abusare del controllo dei messaggi: se il messaggio cambia spesso, i test diventano fragili. Usalo solo quando è davvero utile.
  • Preferisci nomi di test descrittivi: ad esempio test_payment_insufficient_balance_raises è molto più chiaro di test_error_1.
  • Separa validazione e logica di business: così è più facile testare ogni pezzo in modo isolato.

Riepilogo

In questo tutorial abbiamo visto un aspetto molto pratico del testing in Python: la verifica delle eccezioni personalizzate con unittest e pytest. Abbiamo creato una piccola libreria per la gestione di pagamenti, definito errori specifici e scritto test per controllare input non validi, saldo insufficiente e comportamento corretto del flusso principale.

Il punto chiave è questo: testare le eccezioni non serve solo a confermare che il codice “si rompe” nel modo giusto, ma anche a migliorare il debugging, la qualità del design e la manutenzione del progetto. Se i test descrivono bene i casi di errore, trovare e correggere un bug diventa molto più semplice.

Approfondisci con risorse ufficiali

  • Python unittest documentation: documentazione ufficiale del modulo standard per i test.
  • Pytest documentation: guida ufficiale a pytest.raises e alle altre funzionalità di testing.
  • Python built-in exceptions: panoramica sulle eccezioni di base e su come estenderle correttamente.
  • PEP 8: linee guida di stile utili anche per scrivere test leggibili e mantenibili.

SHARE