Test delle eccezioni in Python metodi puliti ed efficaci

Test delle eccezioni in Python

Oltre i Fondamenti: Test Avanzati sulle Eccezioni in Python per Pytest e Unittest

immagine di chenspec su pixabay

Testare le eccezioni è più di una formalità: è una parte essenziale nella scrittura di codice affidabile. In questo tutorial, esploreremo metodi pratici ed efficaci per testare il codice Python che solleva e non solleva eccezioni, verificando l’accuratezza dei messaggi di eccezione e coprendo sia pytest che unittest, con e senza test parametrizzati per ciascun framework.

Alla fine di questo tutorial, avrai una solida comprensione di come scrivere test di eccezione puliti, efficienti e informativi per il tuo codice.

Analizziamo l’esempio seguente:

def divide(num_1: float, num_2: float) -> float:    if not isinstance(num_1, (int, float)) \            or not isinstance(num_2, (int, float)):        raise TypeError("almeno uno dei valori in input "                        f"non è un numero: {num_1}, {num_2}")        return num_1 / num_2

Ci sono diversi casi che possiamo testare per la funzione sopra – un flusso regolare, un denominatore zero e un input non numerico.

Ora vediamo come apparirebbero tali test, utilizzando pytest:

pytest

from contextlib import nullcontext as does_not_raiseimport pytestfrom operations import dividedef test_flusso_regolare():    with does_not_raise():        assert divide(30, 2.5) is not None    assert divide(30, 2.5) == 12.0def test_divisione_per_zero():    with pytest.raises(ZeroDivisionError) as exc_info:        divide(10.5, 0)    assert exc_info.value.args[0] == "float division by zero"def test_non_numero():    with pytest.raises(TypeError) as exc_info:        divide("a", 10.5)    assert exc_info.value.args[0] == \           "almeno uno dei valori in input non è un numero: a, 10.5"

Possiamo anche eseguire un controllo per vedere cosa succede quando testiamo un flusso non valido rispetto al tipo di eccezione sbagliato o quando cerchiamo di verificare una eccezione sollevata in un flusso regolare. In questi casi, i test falliranno:

# Entrambi i test sottostanti dovrebbero falliredef test_eccezione_errata():    with pytest.raises(TypeError) as exc_info:        divide(10.5, 0)    assert exc_info.value.args[0] == "float division by zero"def test_eccezione_inaspettata_in_flusso_regolare():    with pytest.raises(Exception):        assert divide(30, 2.5) is not None

Quindi, perché i test sopra sono falliti? Il contesto with cattura il tipo specifico di eccezione richiesto e verifica che il tipo di eccezione sia effettivamente quello richiesto.

In test_eccezione_errata, un’eccezione (ZeroDivisionError) è stata generata, ma non è stata catturata da TypeError. Pertanto, nello stack trace, vedremo che ZeroDivisionError è stata generata e non è stata catturata dal contesto TypeError.

In test_eccezione_inaspettata_in_flusso_regolare il nostro contesto with pytest.raises ha cercato di convalidare il tipo di eccezione richiesto (abbiamo fornito Exception in questo caso), ma poiché nessuna eccezione è stata generata, il test è fallito con il messaggio Failed: DID NOT RAISE <class ‘Exception’>.

Passando ora alla fase successiva, esploriamo come rendere i nostri test molto più concisi e puliti utilizzando parametrize.

Parametrize

from contextlib import nullcontext as does_not_raiseimport pytestfrom operations import [email protected](    "num_1, num_2, expected_result, exception, message",    [        (30, 2.5, 12.0, does_not_raise(), None),        (10.5, 0, None, pytest.raises(ZeroDivisionError),         "float division by zero"),        ("a", 10.5, None, pytest.raises(TypeError),         "almeno uno dei valori in input non è un numero: a, 10.5")    ],    ids=["input validi",         "divisione per zero",         "input non numerico"])def test_divisione(num_1, num_2, expected_result, exception, message):    with exception as e:        result = divide(num_1, num_2)    assert message is None or message in str(e)    if expected_result is not None:        assert result == expected_result

Il parametro ids cambia il nome del caso di test visualizzato nella vista della barra di test dell’IDE. Nella schermata sottostante possiamo vederlo in azione: con ids a sinistra e senza ids a destra.

screenshot di autore

Ora che abbiamo coperto il framework pytest, vediamo come scrivere gli stessi test utilizzando unittest.

unittest

from unittest import TestCasefrom operations import divideclass TestDivide(TestCase):    def test_happy_flow(self):        result = divide(0, 10.5)        self.assertEqual(result, 0)    def test_division_by_zero(self):        with self.assertRaises(ZeroDivisionError) as context:            divide(10, 0)        self.assertEqual(context.exception.args[0], "divisione per zero")    def test_not_a_digit(self):        with self.assertRaises(TypeError) as context:            divide(10, "c")        self.assertEqual(context.exception.args[0],                         "almeno uno dei valori di input "                         "non è un numero: 10, c")

Se vogliamo utilizzare parameterized con unittest dobbiamo installare il pacchetto. Vediamo come apparirebbero i test parametrizzati in unittest:

Parametrized

import unittestfrom parameterized import parameterized  # richiede l'installazionefrom operations import dividedef get_test_case_name(testcase_func, _, param):    test_name = param.args[-1]    return f"{testcase_func.__name__}_{test_name}"class TestDivision(unittest.TestCase):    @parameterized.expand([        (30, 2.5, 12.0, None, None, "input validi"),        (10.5, 0, None, ZeroDivisionError,         "divisione in virgola mobile per zero", "divisione per zero"),        ("a", 10.5, None, TypeError,         "almeno uno dei valori di input non è un numero: a, 10.5",         "input non numerico")    ], name_func=get_test_case_name)    def test_division(self, num_1, num_2, expected_result, exception_type,                      exception_message, test_name):        with self.subTest(num_1=num_1, num_2=num_2):            if exception_type is not None:                with self.assertRaises(exception_type) as e:                    divide(num_1, num_2)                self.assertEqual(str(e.exception), exception_message)            else:                result = divide(num_1, num_2)                self.assertIsNotNone(result)                self.assertEqual(result, expected_result)

In unittest, abbiamo anche modificato i nomi dei casi di test, simili all’esempio di pytest sopra. Tuttavia, per ottenere questo, abbiamo utilizzato il parametro name_func insieme a una funzione personalizzata.

In conclusione, oggi abbiamo esplorato metodi efficaci per testare le eccezioni Python. Abbiamo imparato a riconoscere quando viene lanciata un’eccezione come previsto e a verificare che il messaggio dell’eccezione corrisponda alle nostre aspettative. Abbiamo esaminato vari modi per testare una funzione divide, inclusa l’approccio tradizionale utilizzando pytest e un approccio più pulito con parametrize. Abbiamo anche esplorato l’equivalente di unittest con parameterized, che richiedeva l’installazione della libreria, così come senza di essa. L’uso di ids e nomi di test personalizzati ha fornito una visualizzazione più pulita e informativa nella barra dei test dell’IDE, facilitando la comprensione e la navigazione dei casi di test. Utilizzando queste tecniche, possiamo migliorare i nostri test unitari e assicurarci che il nostro codice gestisca correttamente le eccezioni.

Buon testing!

immagine di jakob5200 su pixabay