Introduzione
Quando scriviamo test in Python, uno dei problemi più comuni non è verificare la logica “pura” di una funzione, ma isolarla dalle dipendenze esterne: API HTTP, database, filesystem, tempo di sistema, variabili d’ambiente o servizi di terze parti. In questi casi, il test rischia di diventare fragile, lento e difficile da debuggare.
Un approccio molto pratico è usare mock e patch. Con questi strumenti possiamo sostituire temporaneamente una dipendenza reale con un oggetto controllato dal test. In questo tutorial vedremo come farlo con unittest e pytest, concentrandoci su un caso concreto: una funzione che legge un file JSON, valida i dati e calcola un risultato.
Questo sotto-argomento è utile perché permette di affrontare problemi reali di test e debugging: evitare I/O inutili, simulare errori, controllare gli input e capire rapidamente dove il codice si comporta in modo inatteso.
Codice completo
import json
from pathlib import Path
class ConfigError(Exception):
"""Eccezione sollevata quando la configurazione non è valida."""
pass
def load_settings(path: str) -> dict:
"""
Legge un file JSON e restituisce un dizionario di configurazione.
Solleva ConfigError in caso di file mancante, JSON non valido o dati incompleti.
"""
file_path = Path(path)
if not file_path.exists():
raise ConfigError(f"File non trovato: {path}")
try:
content = file_path.read_text(encoding="utf-8")
data = json.loads(content)
except json.JSONDecodeError as exc:
raise ConfigError("JSON non valido") from exc
if "timeout" not in data:
raise ConfigError("Campo obbligatorio mancante: timeout")
if not isinstance(data["timeout"], int) or data["timeout"] <= 0:
raise ConfigError("timeout deve essere un intero positivo")
return data
def build_request_headers(settings: dict) -> dict:
"""
Costruisce header HTTP a partire dalla configurazione.
"""
return {
"X-Timeout": str(settings["timeout"]),
"X-Mode": settings.get("mode", "standard")
} import unittest
from unittest.mock import patch, mock_open, MagicMock
import json
# Supponiamo che il codice sopra sia salvato in un modulo chiamato config_loader
# from config_loader import load_settings, build_request_headers, ConfigError
class TestLoadSettingsWithUnittest(unittest.TestCase):
@patch("config_loader.Path")
def test_file_missing(self, mock_path):
mock_instance = mock_path.return_value
mock_instance.exists.return_value = False
from config_loader import load_settings, ConfigError
with self.assertRaises(ConfigError) as ctx:
load_settings("settings.json")
self.assertIn("File non trovato", str(ctx.exception))
@patch("config_loader.Path")
def test_invalid_json(self, mock_path):
mock_instance = mock_path.return_value
mock_instance.exists.return_value = True
mock_instance.read_text.return_value = "{ invalid json }"
from config_loader import load_settings, ConfigError
with self.assertRaises(ConfigError) as ctx:
load_settings("settings.json")
self.assertEqual("JSON non valido", str(ctx.exception))
@patch("config_loader.Path")
def test_missing_timeout(self, mock_path):
mock_instance = mock_path.return_value
mock_instance.exists.return_value = True
mock_instance.read_text.return_value = json.dumps({"mode": "debug"})
from config_loader import load_settings, ConfigError
with self.assertRaises(ConfigError) as ctx:
load_settings("settings.json")
self.assertIn("Campo obbligatorio mancante", str(ctx.exception))
@patch("config_loader.Path")
def test_valid_settings(self, mock_path):
mock_instance = mock_path.return_value
mock_instance.exists.return_value = True
mock_instance.read_text.return_value = json.dumps({"timeout": 10, "mode": "debug"})
from config_loader import load_settings
settings = load_settings("settings.json")
self.assertEqual(settings["timeout"], 10)
self.assertEqual(settings["mode"], "debug")
class TestBuildRequestHeaders(unittest.TestCase):
def test_build_headers(self):
from config_loader import build_request_headers
headers = build_request_headers({"timeout": 15, "mode": "fast"})
self.assertEqual(headers["X-Timeout"], "15")
self.assertEqual(headers["X-Mode"], "fast")
if __name__ == "__main__":
unittest.main() # Esempio equivalente con pytest
import json
import pytest
from unittest.mock import patch
# from config_loader import load_settings, build_request_headers, ConfigError
def test_file_missing_pytest():
from config_loader import load_settings, ConfigError
with patch("config_loader.Path") as mock_path:
mock_instance = mock_path.return_value
mock_instance.exists.return_value = False
with pytest.raises(ConfigError, match="File non trovato"):
load_settings("settings.json")
def test_invalid_json_pytest():
from config_loader import load_settings, ConfigError
with patch("config_loader.Path") as mock_path:
mock_instance = mock_path.return_value
mock_instance.exists.return_value = True
mock_instance.read_text.return_value = "{ invalid json }"
with pytest.raises(ConfigError, match="JSON non valido"):
load_settings("settings.json")
def test_valid_headers_pytest():
from config_loader import build_request_headers
headers = build_request_headers({"timeout": 20, "mode": "safe"})
assert headers == {
"X-Timeout": "20",
"X-Mode": "safe"
} Spiegazione
Il cuore dell’esempio è la funzione load_settings. In un progetto reale, leggere un file JSON direttamente durante i test sarebbe scomodo: dovremmo creare file temporanei, pulirli dopo ogni test e gestire possibili effetti collaterali. Con mock possiamo invece simulare il comportamento del filesystem.
1. Perché usare patch
patch sostituisce un oggetto nel punto in cui viene usato. Nel nostro caso, la classe Path viene patchata nel modulo config_loader. Questo è importante: non bisogna patchare il riferimento “globale” di pathlib.Path, ma quello importato nel modulo sotto test.
Con la patch possiamo controllare:
- se il file esiste o no con
exists.return_value; - il contenuto letto con
read_text.return_value; - eventuali errori di parsing JSON.
2. Il ruolo di mock e MagicMock
Un mock è un oggetto finto che imita un oggetto reale. In questo esempio non abbiamo usato MagicMock in modo esplicito, ma è utile sapere che può simulare metodi e proprietà con grande flessibilità. È perfetto quando vuoi verificare che una funzione chiami un’altra funzione con certi argomenti, oppure quando vuoi costruire un comportamento controllato per una dipendenza complessa.
3. Come si fa debugging dei test
Se un test fallisce, il primo passo è capire se il problema è nella logica applicativa o nella simulazione. Con i mock, gli errori più frequenti sono:
- patch nel posto sbagliato: il test non sostituisce davvero la dipendenza;
- valore di ritorno non coerente: per esempio,
read_textrestituisce una stringa non valida; - asserzioni troppo deboli: il test passa anche se il comportamento è solo parzialmente corretto.
Per debuggare, puoi stampare temporaneamente il contenuto del mock, oppure ispezionare mock_instance.method_calls e mock_instance.mock_calls. In pytest, puoi anche lanciare i test con -vv per avere output più dettagliato.
4. Differenze pratiche tra unittest e pytest
unittest è molto esplicito e organizzato in classi. È ottimo se vuoi una struttura classica e standard. pytest è più conciso e leggibile, soprattutto per test semplici o parametrizzati. Nel nostro esempio, la logica del test è la stessa, ma pytest richiede meno boilerplate.
In entrambi i casi, il vantaggio principale è lo stesso: testare il comportamento senza dipendere da risorse esterne reali.
Best practice
- Patcha sempre nel modulo giusto: sostituisci il simbolo usato dal codice sotto test, non necessariamente quello originale.
- Isola una sola responsabilità per test: un test deve verificare un comportamento specifico, non tutto insieme.
- Usa messaggi di errore chiari: aiutano sia chi usa il codice sia chi scrive i test.
- Evita di testare l’implementazione interna: preferisci verificare il risultato o il comportamento osservabile.
- Simula solo ciò che serve: mock troppo complessi rendono i test difficili da capire e mantenere.
- Controlla anche i casi di errore: file mancante, dati corrotti, campi assenti, valori non validi.
- Usa pytest quando vuoi sintassi compatta, unittest quando preferisci una struttura più formale o hai già una base di codice legacy.
Riepilogo
In questo tutorial abbiamo visto come usare mock e patch per testare codice Python in modo isolato, con unittest e pytest. L’esempio mostra un caso reale: leggere configurazioni da un file JSON, validarle e costruire un risultato utile per il resto dell’applicazione.
Il punto chiave è che i test diventano più affidabili quando eliminano le dipendenze esterne. Così puoi concentrarti sulla logica, simulare errori in modo preciso e fare debugging più velocemente. Se impari a patchare correttamente e a scrivere asserzioni mirate, i tuoi test saranno più robusti e il codice più facile da mantenere.
Approfondisci con risorse ufficiali
- Python unittest: documentazione ufficiale del modulo standard
unittest - unittest.mock: guida ufficiale per
patch,MockeMagicMock - pytest documentation: documentazione ufficiale di pytest
- pathlib: documentazione ufficiale per la gestione dei percorsi e dei file
- json: documentazione ufficiale del modulo JSON di Python
