pytest è il framework di test più diffuso in Python: scrivi funzioni che iniziano con test_, dentro usi l'istruzione assert, e lui le trova, le esegue e ti dice quali falliscono e perché. Un test è un controllo automatico che risponde a una domanda: "il codice fa ancora quello che pensavo?". In questa lezione, con pytest 9.2, imparerai le cinque mosse che coprono quasi tutto: assert, fixture, parametrizzazione, eccezioni attese e marker. La lezione successiva le applica ai modelli. Presuppone un ambiente creato come in UV, ambienti e dipendenze.
Le parole del testing
Un test è una piccola funzione che esegue una parte del tuo codice e controlla che il risultato sia quello atteso, come l'assaggio del cuoco: un campione, non il pranzo intero. L'asserzione (assertion, istruzione assert) è l'affermazione "questa cosa deve essere vera": se è falsa, il test fallisce. La test suite è l'insieme di tutti i test del progetto, eseguiti insieme, e un test di regressione protegge un comportamento che prima funzionava, perché una modifica futura non lo rompa (la "regressione" è proprio il tornare indietro). Una fixture è la preparazione di ciò che un test richiede (dati, un file, una connessione), ripetibile e pulita come apparecchiare la tavola prima di ogni cena e sparecchiarla dopo. La parametrizzazione esegue lo stesso test con più insiemi di valori, un marker è un'etichetta su un test (per esempio slow) con cui puoi sceglierlo o escluderlo.
Un mock (doppio di prova) è un oggetto finto che sostituisce una parte reale (un database, un servizio di rete) per testare il resto senza dipendere da quella, come il manichino nei test d'impatto. Il codice d'uscita (exit code) è il numero con cui un programma dice come è andata (0 = bene): pytest restituisce 0 se tutto passa e altri numeri se qualcosa non va, ed è ciò che la CI legge.
L'analogia: l'elenco dei controlli prima del decollo
Un pilota esperto esegue la checklist prima del volo, ogni volta, anche se ha fatto quel volo mille volte. Non perché non si fidi di sé, ma perché la checklist è più affidabile della memoria e costa due minuti. I test sono la checklist del tuo codice: ogni volta che cambi qualcosa, in due secondi sai se hai rotto qualcosa che prima funzionava.
Il primo test
Gli esempi che seguono sono stati eseguiti il 2026-11-12 con Python 3.12.13, pytest 9.2.0 e pytest-cov installati da UV in un progetto di prova chiamato pulizia (uv init --bare --python 3.12 pulizia, uv add --dev pytest pytest-cov), due volte in cartelle diverse con gli stessi esiti. Prendiamo una funzione che pulisce l'età grezza letta da un file, tipicamente sporca (spazi, virgola decimale, valori impossibili):
# src/pulizia/eta.py
def normalizza_eta(valore: object) -> int:
"""Converte un valore grezzo in un'età intera valida (0-120)."""
if valore is None or str(valore).strip() == "":
raise ValueError("età mancante")
eta = int(float(str(valore).replace(",", ".")))
if not 0 <= eta <= 120:
raise ValueError(f"età fuori intervallo: {eta}")
return eta
E il test, in tests/test_eta.py:
from pulizia.eta import normalizza_eta
def test_intero_semplice():
assert normalizza_eta(42) == 42
Niente classi, niente metodi speciali: una funzione e un assert. Se la condizione è falsa, il test fallisce. Si lancia con uv run pytest. Nel pyproject.toml dichiari una volta dove sono i test e dove sta il codice:
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
testpaths dice a pytest dove cercare quando lo lanci senza argomenti; pythonpath aggiunge src ai percorsi di importazione, ed è ciò che permette a from pulizia.eta import ... di funzionare con il layout a cartella src/. Se ometti pythonpath il valore predefinito non include src: togliendo la riga ho ottenuto ModuleNotFoundError: No module named 'pulizia' e 1 error in raccolta.
Parametrizzazione: un test, molti casi
Gli errori nascono ai bordi: valori minimi, massimi, formati strani. Invece di scrivere dieci test quasi uguali, ne scrivi uno e gli passi i casi:
import pytest
@pytest.mark.parametrize(
"grezzo, atteso",
[("42", 42), (" 7 ", 7), ("33,9", 33), (0, 0), (120, 120)],
)
def test_formati_validi(grezzo, atteso):
assert normalizza_eta(grezzo) == atteso
pytest esegue il test cinque volte e le segnala separatamente, con il caso nel nome (test_formati_validi[ 7 -7]). Quando uno fallisce sai esattamente quale. Nota il caso "33,9" che dà 33: la funzione tronca, non arrotonda. Il test documenta la decisione, e se qualcuno la cambia, il test lo dice.
Eccezioni attese
Anche il fallimento corretto è un comportamento da testare: un'età di 200 anni deve produrre un errore, non passare in silenzio.
@pytest.mark.parametrize("grezzo", [None, "", " ", -1, 121, "999"])
def test_valori_non_validi_sollevano_errore(grezzo):
with pytest.raises(ValueError):
normalizza_eta(grezzo)
def test_messaggio_di_errore_e_utile():
with pytest.raises(ValueError, match="fuori intervallo"):
normalizza_eta(200)
match= verifica anche il messaggio, come espressione regolare cercata nel testo dell'errore: un errore che dice cosa è andato storto vale più di un errore muto.
Fixture: preparare i dati una volta, in modo pulito
Quando più test hanno bisogno degli stessi dati di partenza, li prepari in una fixture e il test la richiede semplicemente nominandola come argomento. Va in tests/conftest.py, un file che pytest legge da solo:
import pytest
@pytest.fixture
def clienti():
"""Dati piccoli e costruiti a mano: ogni test ne riceve una copia nuova."""
return [{"id": 1, "eta": "34"}, {"id": 2, "eta": "61,5"}]
def test_tutti_i_clienti_hanno_eta_valida(clienti):
assert [normalizza_eta(c["eta"]) for c in clienti] == [34, 61]
Per impostazione predefinita una fixture viene ricreata per ogni test: così nessun test sporca i dati di un altro. Se la preparazione è costosa (addestrare un modello, per esempio) puoi allargare lo scope, con @pytest.fixture(scope="module"): ricreata una volta per file. È un compromesso tra velocità e isolamento, e lo useremo nella lezione su come si testa un modello.
pytest offre fixture già pronte. Le più utili sono tmp_path (una cartella temporanea che sparisce a fine test, per testare scritture di file) e monkeypatch (modifica variabili d'ambiente o attributi solo per la durata del test, poi ripristina):
def test_variabile_ambiente(monkeypatch):
monkeypatch.setenv("ETA_MAX", "100")
import os
assert os.environ["ETA_MAX"] == "100"
Ritroverai monkeypatch nella lezione sulle variabili d'ambiente, per testare la configurazione senza toccare l'ambiente reale.
Marker: separare i test veloci dai lenti
Un test lento che gira a ogni salvataggio viene presto ignorato. Un marker è un'etichetta, e la dichiari nel pyproject.toml perché pytest ti avvisi se sbagli il nome:
[tool.pytest.ini_options]
markers = ["slow: test lenti, esclusi di default con -m 'not slow'"]
Poi applichi il marker al test lento con il decoratore @pytest.mark.slow, che non cambia nulla nel corpo del test ma lo etichetta:
@pytest.mark.slow
def test_scansione_lunga():
assert sum(range(1_000_000)) > 0
Infine scegli a riga di comando se includere o escludere i test etichettati; i conteggi qui sotto vengono dal progetto di prova:
uv run pytest -q # tutto
# 20 passed
uv run pytest -q -m "not slow" # senza i lenti
# 19 passed, 1 deselected
I conteggi sono quelli del progetto di prova con il file di test completo (20 test); il testo del marker dice "esclusi di default", ma l'esclusione avviene solo se passi tu -m 'not slow' (o la metti in addopts): il marker da solo etichetta e basta. Nelle lezioni successive useremo un marker per i controlli sul modello.
Leggere un test rosso
Ho cambiato di proposito l'atteso del caso " 7 " da 7 a 8. Questo è ciò che pytest mostra:
__________________________ test_formati_validi[ 7 -8] __________________________
grezzo = ' 7 ', atteso = 8
@pytest.mark.parametrize(
"grezzo, atteso",
[("42", 42), (" 7 ", 8), ("33,9", 33), (0, 0), (120, 120)],
)
def test_formati_validi(grezzo, atteso):
> assert normalizza_eta(grezzo) == atteso
E AssertionError: assert 7 == 8
E + where 7 = normalizza_eta(' 7 ')
tests/test_eta.py:16: AssertionError
=========================== short test summary info ============================
FAILED tests/test_eta.py::test_formati_validi[ 7 -8] - AssertionError: assert...
1 failed, 19 passed
Si legge dal basso: quale test (FAILED ...), con quali valori (grezzo = ' 7 '), quale riga (>), cosa si aspettava e cosa ha ottenuto (assert 7 == 8). Prima di cambiare il codice, chiediti sempre: è sbagliato il codice o è sbagliato il test? In questo caso era il test.
Come sono i buoni test
Cinque qualità distinguono un buon test:
- Piccolo: una cosa per test, con un nome che dice quale (
test_valori_non_validi_sollevano_errore). - Veloce: nell'ordine dei millisecondi, perché se sono lenti si smette di lanciarli.
- Indipendente: l'ordine non conta e nessuno dipende dal risultato di un altro.
- Deterministico: stesso esito a ogni esecuzione, con seed fisso nei dati casuali.
- Sui comportamenti, non sulle righe: testa cosa fa la funzione, non come lo fa.
Le prime quattro proteggono la fiducia nella suite: un test lento, che dipende da un altro o che a volte fallisce senza motivo viene presto ignorato, e una suite ignorata non protegge nulla. L'ultima protegge il codice: se i test descrivono il comportamento puoi riscrivere l'interno senza riscrivere i test.
Trappole
La prima sono i test che non girano: un file chiamato check_extra.py o una funzione senza prefisso test_ viene ignorata, e la suite resta verde. Guarda il numero di test eseguiti, non solo il colore; nel progetto di questo sito alcuni spec del frontend sono esclusi dalla configurazione e la suite resta verde senza averli eseguiti, per questo il conteggio vale quanto l'esito. La seconda sono i test che passano sempre, per esempio un assert su qualcosa che è sempre vero: prova a rompere il codice apposta e verifica che il test diventi rosso. La terza sono i dati condivisi modificati: una fixture con scope largo che i test modificano genera fallimenti che dipendono dall'ordine. La quarta è testare troppo: test che replicano riga per riga l'implementazione si rompono a ogni refactoring senza proteggere nulla.
Come pytest trova i test: la scoperta
pytest non va "configurato" per sapere cosa eseguire: segue convenzioni sui nomi. Cerca i file test_*.py o *_test.py, e dentro di essi le funzioni che iniziano con test_ (e le classi Test*). Quello che non rispetta il nome non viene raccolto, senza alcun avviso: è la trappola dei "test che non girano". Per vedere cosa ha raccolto senza eseguirlo:
uv run pytest --collect-only -q
# tests/test_eta.py::test_intero_semplice
# tests/test_eta.py::test_formati_validi[42-42]
# tests/test_eta.py::test_formati_validi[ 7 -7]
# ...
# 20 tests collected in 0.04s
Stampa un identificatore per ogni test: file, funzione e, tra parentesi quadre, il caso parametrizzato. Prima di fidarti di una suite verde, controlla che l'elenco contenga ciò che ti aspetti. Nella prova ho copiato un file di test con il nome check_extra.py: uv run pytest -q ha continuato a dire 22 passed, prima e dopo, mentre indicando il file a mano (uv run pytest -q tests/check_extra.py) ha eseguito il suo test (1 passed).
I comandi per lavorare ogni giorno
Ho provato queste opzioni sul progetto di prova:
| Comando | Cosa fa | Risultato nella prova |
|---|---|---|
pytest -q | Esegue tutto, output compatto | 20 passed |
pytest -k "approx or mock" | Solo i test il cui nome contiene quelle parole | 2 passed, 18 deselected |
pytest -x | Si ferma al primo fallimento | 1 failed, 2 passed (con un test reso rosso di proposito): i restanti non sono stati eseguiti |
pytest --lf | Riesegue solo quelli falliti all'ultimo giro | 1 failed: solo quello |
pytest -m "not slow" | Esclude un marker | 19 passed, 1 deselected |
pytest -s | Mostra le print (di solito catturate) | utile per il debug (non provato) |
Un ciclo di lavoro tipico: scrivi il test, pytest -x per vedere il primo fallimento, correggi, pytest --lf per riprovare solo quello, poi pytest intero prima del commit.
Un dettaglio che tradisce: eseguendo pytest -k "nessunotest", che non seleziona alcun test, il codice d'uscita è 5 (non 0, non 1): "nessun test raccolto" (misurato con echo $?). È un fallimento, e giustamente, ma alcuni script lo trattano come un successo. Se una CI è configurata per ignorare il 5, una suite vuota passa per verde.
Confrontare numeri decimali: pytest.approx
I numeri in virgola mobile non sono esatti: in Python 0.1 + 0.2 == 0.3 è False, perché il calcolatore rappresenta quei valori in binario con un'approssimazione minima. In ML, dove ogni risultato è un decimale, un confronto con == fallisce per ragioni che non c'entrano con un errore. Si usa pytest.approx:
def test_float_con_approx():
assert 0.1 + 0.2 != 0.3 # i float non sono esatti
assert 0.1 + 0.2 == pytest.approx(0.3) # confronto con tolleranza
Il primo assert dimostra il fenomeno (passa: davvero sono diversi), il secondo il rimedio. Per impostazione predefinita la tolleranza relativa è 1e-6, un milionesimo del valore atteso (documentazione di pytest.approx); si può regolare con pytest.approx(0.3, abs=1e-3) quando serve una soglia più larga. Lo ritroverai nei test di modelli.
File temporanei: tmp_path
Un test che scrive su disco non deve sporcare il tuo progetto né dipendere da file lasciati da un'esecuzione precedente. tmp_path è una fixture che fornisce una cartella nuova e vuota per ogni test, ripulita poi da pytest secondo la propria politica di conservazione:
def test_scrittura_su_file_temporaneo(tmp_path):
destinazione = tmp_path / "risultati.json"
destinazione.write_text(json.dumps({"auc": 0.71}))
assert json.loads(destinazione.read_text())["auc"] == pytest.approx(0.71)
Il test richiede la fixture scrivendone il nome come parametro (serve anche import json in cima al file); pytest la crea e gliela passa. L'oggetto è un Path della libreria standard, quindi / compone i percorsi.
Fixture con preparazione e pulizia: yield
Quando una fixture apre qualcosa (un file, una connessione), deve anche chiuderlo. Si usa yield: ciò che sta prima è la preparazione, ciò che sta dopo viene eseguito alla fine del test, anche se il test è fallito:
@pytest.fixture
def risorsa():
eventi = ["apri"]
yield eventi # il test gira qui, ricevendo "eventi"
eventi.append("chiudi") # teardown: sempre, dopo il test
def test_fixture_con_yield(risorsa):
assert risorsa == ["apri"]
Dentro il test la lista contiene solo "apri"; l'aggiunta di "chiudi" avviene dopo, ed è per questo che non puoi verificarla dal test stesso. In una fixture reale al posto della lista ci sarebbe una connessione e al posto dell'append la sua chiusura.
Sostituire un pezzo reale: il mock
Un test deve essere veloce e deterministico, quindi non dovrebbe chiamare una rete, un database o un modello da gigabyte. Si sostituisce la parte lenta con un doppio:
from unittest.mock import Mock
def test_doppio_con_mock():
servizio = Mock()
servizio.leggi.return_value = " 41 "
assert normalizza_eta(servizio.leggi()) == 41
servizio.leggi.assert_called_once()
Mock() crea un oggetto che accetta qualunque chiamata; return_value stabilisce cosa restituisce il metodo leggi; l'ultima riga verifica che sia stato chiamato esattamente una volta. Il pericolo del mock è dimenticare di testare anche la cosa reale: i doppi dicono che il tuo codice usa bene l'interfaccia, non che l'interfaccia funzioni. Un test verde su un doppio non sostituisce una prova, almeno una volta, contro la cosa vera.
Un esempio reale dal servizio ML di questo sito
Il file ml-service/tests/test_cache_redis_down.py verifica che il servizio non si pianti quando Redis (la cache) non risponde. Costruisce un Redis finto che solleva un errore di connessione e controlla che il servizio degradi con grazia. Il test è scritto con i commenti Arrange, Act, Assert, che rendono esplicite le tre parti di un buon test; qui è abbreviato (nel file sta dentro una classe) e i commenti sono tradotti:
@pytest.mark.asyncio
async def test_get_returns_none_when_redis_raises():
# Arrange: preparo le condizioni (un Redis che solleva un errore)
redis = AsyncMock()
redis.get = AsyncMock(side_effect=ConnectionError("Redis connection refused"))
cache = CacheService(redis=redis)
# Act: eseguo l'azione da testare
result = await cache.get("any query")
# Assert: verifico il risultato
assert result is None
AsyncMock è la versione di Mock per le funzioni asincrone; side_effect=ConnectionError(...) fa sollevare un errore al posto di restituire un valore. Il test dice: "se la cache è giù, get restituisce None e non propaga l'errore". È un comportamento da specificare per un servizio in produzione, e senza il mock servirebbe spegnere Redis per provarlo. Arrange costruisce i dati e i doppi, Act chiama una sola cosa, Assert controlla l'esito. Se un test ha più "Act", probabilmente sono due test; se l'Arrange è lunghissimo, una fixture lo renderebbe più leggibile; se l'Assert controlla dieci cose, quando fallirà non saprai quale. Questo blocco non è stato eseguito: richiede il ml-service del repository e le sue dipendenze, e i test del repository non vanno lanciati da questo lavoro; ho solo verificato che il file esista e contenga il test. La cartella ml-service/tests/ ha 64 file test_*.py al primo livello (ls tests/test_*.py | wc -l, 2026-11-12).
Il perché: la paura di toccare il codice
Chi lavora su codice senza test conosce una sensazione precisa: il timore di modificare qualcosa che funziona. Ogni cambiamento è una scommessa, perché non c'è modo rapido di sapere cosa si è rotto. La conseguenza è che il codice smette di evolvere: non si migliorano i nomi, non si semplificano le funzioni, non si aggiornano le librerie, perché "meglio non toccare". Nel machine learning la paura è ancora più giustificata: una modifica a una funzione di pulizia dei dati può peggiorare il modello senza alcun errore (il degrado silenzioso di cui parla l'introduzione al modulo). I test sono l'unica risposta onesta a questa paura, perché la trasformano da stato d'animo in informazione: "ho cambiato questa cosa e questi 21 controlli dicono che il resto si comporta come prima".
C'è un secondo motivo, meno ovvio: i test sono documentazione che non invecchia. Un commento può dire il falso dopo sei mesi; un test che fallisce non può. Il test "33,9" -> 33 della lezione dice in modo eseguibile che la funzione tronca e non arrotonda: chiunque cambi quel comportamento è costretto a leggere e decidere.
Quali test scrivere: la piramide
Non tutti i test sono uguali. Tre livelli, dal più piccolo al più grande, spesso disegnati come piramide perché ne servono molti in basso e pochi in alto:
| Livello | Cosa verifica | Velocità | Esempio nel progetto churn |
|---|---|---|---|
| Unitario | Una funzione o una classe da sola | Millisecondi | normalizza_eta("33,9") == 33 |
| Di integrazione | Più parti insieme (e magari un servizio vero, come un database) | Decimi di secondo o secondi | addestro, salvo, ricarico e predico (lezione sul test dei modelli) |
| End-to-end | L'intero sistema dal punto di vista dell'utente | Secondi o minuti | container avviato, richiesta HTTP a /predict, risposta controllata |
La forma a piramide ha un motivo economico: i test unitari sono veloci, stabili e dicono dove è il problema; quelli end-to-end sono lenti, più fragili e dicono solo che c'è un problema. Una suite fatta solo di end-to-end è lenta e frustrante; una fatta solo di unitari non scopre i difetti nelle giunture tra le parti. Il servizio ML di questo sito ha test unitari (come quelli della cache), test di infrastruttura che richiedono servizi esterni in funzione e controlli di valutazione marcati, e fa valere un limite di copertura dell'80% solo su una parte (il modulo fiscale, con un file di configurazione di pytest a parte, come dice il commento nel suo pyproject.toml): una soglia applicata dove il rischio è maggiore, non indiscriminatamente.
Scrivere il test prima: il ciclo rosso, verde, ristruttura
Il TDD (test-driven development, sviluppo guidato dai test) inverte l'ordine: prima si scrive il test che descrive il comportamento voluto, si vede fallire (rosso), poi si scrive il codice minimo per farlo passare (verde), poi si ripulisce (ristruttura) con la rete di sicurezza dei test. Non è un dogma, è un modo di pensare: costringe a definire cosa vuoi prima di come. Un esempio reale con la nostra funzione. Voglio che un valore come "n/d" (non disponibile) produca un errore chiaro. Scrivo prima il test:
def test_testo_non_disponibile_e_un_errore_chiaro():
with pytest.raises(ValueError, match="mancante"):
normalizza_eta("n/d")
Lo eseguo, ed è rosso, ma in modo istruttivo:
E AssertionError: Regex pattern did not match.
E Expected regex: 'mancante'
E Actual message: "could not convert string to float: 'n/d'"
1 failed, 20 passed
Il codice non crashava in silenzio: sollevava già un ValueError, ma con un messaggio incomprensibile per chi legge i log ("could not convert string to float"). Il test ha reso visibile un difetto che non avevo cercato. Poi scrivo il codice minimo: tratto "n/d" (e qualche variante) come valore mancante.
if valore is None or str(valore).strip().lower() in ("", "n/d", "nan", "none"):
raise ValueError("età mancante")
Rilancio: verde (21 passed). Poi la fase di ristruttura: con la suite verde posso rinominare, spostare, semplificare, sapendo che se rompo qualcosa lo vedo subito. Nota il dettaglio importante: il test è stato visto fallire prima. Un test mai visto rosso potrebbe non verificare nulla; la lezione sul test dei modelli lo chiama "controllare il controllo".
La copertura, con gli occhi aperti
La copertura (coverage) misura quali righe del codice sono state eseguite dai test. Con il plugin pytest-cov:
uv add --dev pytest-cov
uv run pytest -q --cov=pulizia --cov-report=term-missing
# Name Stmts Miss Cover Missing
# -------------------------------------------------------
# src/pulizia/__init__.py 0 0 100%
# src/pulizia/eta.py 7 0 100%
# -------------------------------------------------------
# TOTAL 7 0 100%
Colonne: Stmts le istruzioni, Miss quelle mai eseguite, Cover la percentuale, Missing i numeri di riga non coperti. Il 100% qui dice che ogni riga di eta.py è stata eseguita almeno una volta, non che ogni comportamento sia verificato. Per cogliere di più c'è la copertura dei rami (--cov-branch): conta anche se ogni if è stato visto sia vero sia falso. Ho provato con una funzione di tre fasce di età e un solo test (fascia_eta(30)):
# src/pulizia/fascia.py
def fascia_eta(eta: int) -> str:
if eta < 18:
return "minorenne"
if eta < 65:
return "adulto"
return "senior"
# uv run pytest -q --cov=pulizia --cov-branch --cov-report=term-missing
# src/pulizia/fascia.py 6 2 4 2 60% 3, 6
Il rapporto indica le righe 3 e 6 non eseguite (i casi "minorenne" e "senior"): il test copre un percorso su tre. Questa è la forma d'uso corretta: la copertura come mappa dei punti ciechi, che ti dice dove guardare, non come numero da gonfiare. Una copertura del 100% con test senza asserzioni è peggio di una del 70% con test seri, perché dà fiducia senza meritarla. Il commento nel pyproject.toml del servizio ML di questo sito mostra la maturità giusta: riporta la copertura di base misurata (57%, con 33 test di infrastruttura falliti perché mancano i servizi) e fissa la soglia dell'80% solo dove è sostenibile. Dichiarare il punto di partenza è meglio che fingere un numero.
Debuggare un test rosso
Quando un test fallisce, l'ordine delle domande conta. Prima leggi il messaggio dal basso: quale test, quale asserzione, quali valori. Poi chiediti se è il codice o è il test a essere sbagliato. Se non è evidente, mostra più contesto con l'opzione -l (--showlocals), che aggiunge le variabili locali al rapporto; per i test parametrizzati i valori dei parametri (grezzo = ' 7 ', atteso = 8) pytest li mostra già da solo, come nel rapporto di sopra, quindi -l serve soprattutto per le variabili create dentro la funzione. Per isolare, pytest -x -k nome_del_test esegue solo quello e si ferma. Se serve indagare dentro, pytest --pdb apre il debugger Python nel punto del fallimento (non l'ho provato qui), dove puoi stampare variabili ed eseguire espressioni. Dopo la correzione riesegui tutta la suite, perché una correzione può rompere altro; e se il test fallisce solo a volte hai un problema di instabilità, non di logica.
Il caso reale: la suite non è mai "verde", e il numero conta
Le regole di lavoro del progetto contengono una nota sui test del frontend che è una piccola lezione di onestà metodologica. In sintesi: la suite non è verde, ci sono rossi preesistenti catalogati in un inventario; il criterio per giudicare una modifica non è "zero rossi" ma il delta (nessun test nuovo rosso rispetto a prima della modifica). E c'è una trappola: alcuni file di test sono esclusi dalla configurazione, quindi non girano mai, e la suite resta verde senza averli provati; per questo si controlla il numero di test eseguiti, non solo il colore. Un'altra: lanciare i test con uno strumento diverso da quello autorizzato produce fallimenti falsi in massa.
Ne ricavi tre abitudini valide per ogni progetto, anche il tuo. Misura il delta e non l'assoluto quando c'è un debito storico: prima di modificare, esegui i test e salva l'elenco dei fallimenti, dopo confronta. Conta i test: "50 passed" prima e "30 passed" dopo è un problema anche se non c'è alcun rosso. E usa lo strumento che il progetto indica per eseguire la suite, perché un comando "quasi uguale" può dare risultati diversi per ragioni estranee al codice.
Un piccolo schema di lavoro quotidiano con i test
- Prima di modificare:
uv run pytest -qe annota il risultato (quanti test, quanti rossi). - Scrivi o aggiorna un test per il comportamento che vuoi ottenere; vedilo rosso.
- Scrivi il codice; vedilo verde.
- Esegui la suite intera e controlla il delta rispetto al punto 1.
- Controlla la copertura dei file toccati (
--cov) per vedere se hai lasciato rami non provati. - Committa codice e test insieme (come in Git): così
git bisectha sempre commit coerenti.
L'ordine ha una ragione per ogni passo: il punto 1 dà il riferimento senza cui il punto 4 non ha senso, il 2 prima del 3 garantisce di avere visto il test fallire, e il 6 chiude il giro in modo che la storia del repository racconti test e codice nello stesso commit.
Prova tu: quattro esercizi guidati
Esercizio 1: la prima suite
Crea un progetto UV (uv init --bare --python 3.12 pulizia, uv add --dev pytest), scrivi la funzione normalizza_eta della lezione e i test per tre input validi e tre non validi. Dichiara testpaths e pythonpath in pyproject.toml. Dovresti vedere N passed con uv run pytest -q. Cosa impari: l'impianto minimo, e che senza pythonpath = ["src"] un layout con cartella src/ dà ModuleNotFoundError (l'ho riprodotto).
Esercizio 2: il ciclo rosso, verde
Scrivi un test per un comportamento che il codice non ha ancora (per esempio: l'età come stringa con unità, "34 anni", deve dare 34). Eseguilo e vedilo fallire, leggendo il messaggio dal basso. Poi scrivi il codice minimo che lo fa passare. Cosa impari: a usare il test come specifica; e, osservando il messaggio rosso, a capire se il difetto è nel codice o nel test.
Esercizio 3: il test che non gira
Copia un file di test dandogli un nome che pytest non riconosce (check_extra.py al posto di test_extra.py) e riesegui uv run pytest -q. Dovresti vedere che il numero di test eseguiti non cambia (nella mia prova, 22 prima e 22 dopo): il file viene ignorato in silenzio. Poi esegui uv run pytest -q tests/check_extra.py indicando il file a mano: stavolta gira. Usa uv run pytest --collect-only -q per vedere cosa viene raccolto. Cosa impari: a diffidare di una suite verde e a controllare sempre il numero di test.
Esercizio 4: la copertura dei rami
Scrivi una funzione con tre rami (per esempio una che assegna una fascia di età: sotto i 18, tra 18 e 64, 65 o più) e un solo test. Esegui uv add --dev pytest-cov e uv run pytest --cov=pulizia --cov-branch --cov-report=term-missing. Dovresti vedere una copertura parziale con l'elenco delle righe non eseguite (nella mia prova, 60% con le righe 3 e 6 mancanti). Aggiungi i test mancanti con parametrize fino a coprire tutto, ma poi chiediti: i test verificano le fasce giuste, o si limitano a eseguire il codice? Cosa impari: la copertura come mappa dei punti ciechi, non come obiettivo.
Un quinto esercizio, per chi ha già un progetto: scegli la funzione che temi di più di rompere e scrivi i tre test che ti farebbero dormire sereno. Di solito sono: un caso normale, un caso limite e un input non valido.
Cosa sai fare adesso
Sai scrivere un test come funzione test_... con un assert e lanciarlo con uv run pytest. Sai usare parametrize per i casi limite, pytest.raises per gli errori attesi, le fixture per i dati di partenza e i marker per separare i lenti. Sai leggere un test rosso dal basso (quale, con quali valori, atteso contro ottenuto), vedere un test fallire prima di fidarti che passi e controllare quanti test girano, non solo se sono verdi. La prossima lezione porta tutto questo sui modelli: testare un modello con invarianti, soglie e dati.
Nella stessa serie trovi anche ML-Ops: usare bene l'open source, dalle licenze al contributo e ML-Ops: il deploy, dove gira il modello e come scegliere.
Fonti
- Documentazione di pytest - la scoperta dei test, il codice d'uscita 5 e le opzioni
-k,-x,--lf,-l, consultata il 2026-11-12 - pytest: parametrizzazione -
@pytest.mark.parametrize, consultata il 2026-11-12 - pytest: fixture - scope,
yield,tmp_path,monkeypatch, consultata il 2026-11-12 - pytest: marker - la dichiarazione dei marker nel
pyproject.toml, consultata il 2026-11-12 - pytest.approx - la tolleranza relativa predefinita 1e-6, consultata il 2026-11-12
- Documentazione di pytest-cov -
--cov,--cov-branch,--cov-report=term-missing, consultata il 2026-11-12
Tutti gli esempi con pytest sono stati eseguiti con Python 3.12.13, pytest 9.2.0 e pytest-cov in un progetto di prova, due volte in cartelle diverse: stessi conteggi, tempi omessi. Il test di ml-service è abbreviato dal file del repository e non è stato eseguito; -s e --pdb non sono stati provati. Le cifre 57% e 33 test falliti sono quelle riportate nel commento del pyproject.toml del servizio (misura storica dichiarata lì, non rimisurata). L'età e i clienti dell'esempio sono inventati.
Commenti
Caricamento commenti...
Accedi per commentare