Introduzione
Quando un programma deve gestire molti casi simili, scrivere una funzione di test separata per ogni combinazione di dati può rendere la suite lunga, ripetitiva e difficile da mantenere. I test parametrizzati risolvono questo problema: la stessa logica di verifica viene eseguita con più insiemi di input e risultati attesi.
In questo tutorial costruiremo un piccolo esempio reale: una funzione che calcola il prezzo finale di un prodotto applicando uno sconto e arrotondando il risultato a due cifre decimali. Testeremo la funzione sia con unittest sia con pytest. Vedremo inoltre come individuare i test instabili, cioè quei test che a volte passano e a volte falliscono senza che il codice sia realmente cambiato.
L’obiettivo non è soltanto verificare che il codice funzioni, ma creare test leggibili, ripetibili e facili da diagnosticare.
Codice completo
Creiamo una semplice struttura di progetto:
progetto/
├── prezzi.py
└── tests/
├── test_prezzi_unittest.py
└── test_prezzi_pytest.py Il modulo principale contiene la funzione da verificare:
# prezzi.py
def prezzo_scontato(prezzo, percentuale_sconto):
"""Restituisce il prezzo finale arrotondato a due decimali."""
if prezzo < 0:
raise ValueError("Il prezzo non può essere negativo")
if not 0 <= percentuale_sconto <= 100:
raise ValueError("Lo sconto deve essere compreso tra 0 e 100")
risultato = prezzo * (1 - percentuale_sconto / 100)
return round(risultato, 2) Con unittest possiamo definire i casi in una lista e iterare su di essi all’interno di un singolo test:
# tests/test_prezzi_unittest.py
import unittest
from prezzi import prezzo_scontato
class TestPrezzoScontato(unittest.TestCase):
def test_casi_validi(self):
casi = [
(100, 10, 90),
(59.90, 20, 47.92),
(25, 0, 25),
(80, 100, 0),
(19.999, 10, 18),
]
for prezzo, sconto, atteso in casi:
with self.subTest(prezzo=prezzo, sconto=sconto):
risultato = prezzo_scontato(prezzo, sconto)
self.assertEqual(risultato, atteso)
def test_input_non_validi(self):
casi_invalidi = [
(-10, 20),
(100, -1),
(100, 101),
]
for prezzo, sconto in casi_invalidi:
with self.subTest(prezzo=prezzo, sconto=sconto):
with self.assertRaises(ValueError):
prezzo_scontato(prezzo, sconto)
if __name__ == "__main__":
unittest.main() Il metodo subTest() è importante: se un caso fallisce, unittest mostra i valori associati a quel caso, invece di interrompere immediatamente tutta la verifica.
Con pytest, la parametrizzazione è più compatta grazie al decoratore parametrize:
# tests/test_prezzi_pytest.py
import pytest
from prezzi import prezzo_scontato
@pytest.mark.parametrize(
"prezzo, sconto, atteso",
[
(100, 10, 90),
(59.90, 20, 47.92),
(25, 0, 25),
(80, 100, 0),
(19.999, 10, 18),
],
)
def test_prezzo_scontato(prezzo, sconto, atteso):
assert prezzo_scontato(prezzo, sconto) == atteso
@pytest.mark.parametrize(
"prezzo, sconto",
[
(-10, 20),
(100, -1),
(100, 101),
],
)
def test_prezzo_scontato_rifiuta_input_invalidi(prezzo, sconto):
with pytest.raises(ValueError):
prezzo_scontato(prezzo, sconto) Per installare pytest e lanciare i test:
python -m pip install pytest
python -m unittest discover
pytest -q Spiegazione
Perché usare la parametrizzazione
Ogni riga dei dati rappresenta uno scenario indipendente. Questo permette di aggiungere un nuovo caso senza duplicare il corpo del test. La parametrizzazione è particolarmente utile per:
- funzioni matematiche e di conversione;
- validazione di dati provenienti da utenti;
- parser di stringhe, date o codici;
- calcolo di tariffe, sconti e imposte;
- test dei limiti, come zero, valori massimi e input vuoti.
Con unittest, subTest() conserva un solo metodo di test ma rende visibile lo scenario fallito. Con pytest, invece, ogni combinazione viene esposta come un caso separato nell’output. Questo rende più semplice individuare rapidamente il valore problematico.
Debug di un caso fallito
Supponiamo che il caso relativo a (59.90, 20, 47.92) fallisca. La prima operazione consiste nel leggere input, risultato effettivo e valore atteso. Possiamo aggiungere temporaneamente un messaggio esplicativo:
def test_prezzo_scontato(prezzo, sconto, atteso):
risultato = prezzo_scontato(prezzo, sconto)
assert risultato == atteso, (
f"Input: prezzo={prezzo}, sconto={sconto}; "
f"ottenuto={risultato}, atteso={atteso}"
) Per eseguire soltanto un test pytest, possiamo usare il percorso del file e il nome della funzione:
pytest tests/test_prezzi_pytest.py::test_prezzo_scontato -vv Se il problema non è immediato, il debugger integrato di Python può fermarsi sulla riga desiderata:
def prezzo_scontato(prezzo, percentuale_sconto):
breakpoint()
risultato = prezzo * (1 - percentuale_sconto / 100)
return round(risultato, 2) Durante l’esecuzione è possibile ispezionare le variabili con comandi come p prezzo, p percentuale_sconto e n per passare alla riga successiva. Il comando c continua l’esecuzione.
Riconoscere i test instabili
Un test è instabile quando il suo risultato dipende da condizioni esterne o non controllate, per esempio l’orario di sistema, l’ordine casuale dei dati, un file temporaneo o una variabile globale modificata da un altro test.
Un buon test parametrizzato deve essere:
- deterministico: gli stessi input producono sempre lo stesso risultato;
- isolato: un caso non deve modificare lo stato di un altro;
- indipendente dall’ordine: i test devono passare anche se eseguiti in sequenza diversa;
- leggibile: i dati devono spiegare chiaramente lo scenario.
Pytest può ripetere una suite per aiutare a diagnosticare l’instabilità:
pytest --count=20 tests/ Questa opzione richiede il plugin pytest-repeat. Se un test fallisce soltanto in alcune ripetizioni, è probabile che dipenda da stato condiviso, casualità o risorse esterne.
Best practice
- Usa nomi descrittivi per i parametri e per i test.
- Includi sempre casi normali, casi limite e input non validi.
- Evita di inserire troppi scenari diversi nello stesso test: se la logica cambia, separa i gruppi.
- Usa valori decimali con attenzione: per applicazioni finanziarie valuta
Decimalinvece difloat. - Non correggere un test instabile aumentando semplicemente il numero di tentativi: trova la causa del problema.
- Esegui il singolo caso fallito prima dell’intera suite, così il ciclo di debugging è più veloce.
- Mantieni i dati di test vicini al test, a meno che non siano riutilizzati in più moduli.
Riepilogo
I test parametrizzati permettono di verificare molti scenari con una sola struttura di test, riducendo duplicazioni e aumentando la copertura. In unittest possiamo usare subTest(), mentre in pytest il decoratore @pytest.mark.parametrize offre una sintassi più compatta e un output dettagliato.
Quando un caso fallisce, bisogna analizzare input, risultato ottenuto e risultato atteso, eseguire il test in isolamento e, se necessario, usare breakpoint(). La ripetibilità e l’indipendenza dei casi sono fondamentali per evitare test instabili e poco affidabili.
