Design dei test e TDD — cosa rende un buon test
Pianificare un test prima di scriverlo, Arrange-Act-Assert, testare il comportamento invece dell'implementazione, red-green-refactor, property-based testing con Hypothesis, cause e rimedi dei test flaky, e un test di regressione per ogni bug.
Come vuoi imparare?
Il problema che stiamo risolvendo
Conosci ormai ogni strumento che questo corso voleva insegnarti: assert, raises, parametrize, fixture, mock, marker, configurazione, coverage, plugin. Ed è del tutto possibile usarli tutti e finire comunque con test che fanno più danni che bene.
Ecco un piccolo carrello della spesa, con due test che passano:
class Cart:
def __init__(self):
self._items = []
def add(self, name, price, quantity=1):
self._items.append((name, price, quantity))
def total(self):
return sum(price * quantity for _, price, quantity in self._items)from cart import Cart
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart._items == [("pen", 15.0, 2)]
def test_total():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0.. [100%]
2 passed in 0.01sOra qualcuno migliora il carrello. Aggiungere due volte lo stesso articolo dovrebbe unirlo in una sola riga, quindi la lista diventa un dizionario indicizzato per nome. Il carrello continua ad aggiungere cose e a calcolarne correttamente il totale — niente di ciò che un cliente potrebbe notare è cambiato:
class Cart:
def __init__(self):
self._lines = {}
def add(self, name, price, quantity=1):
_, already = self._lines.get(name, (price, 0))
self._lines[name] = (price, already + quantity)
def total(self):
return sum(price * quantity for price, quantity in self._lines.values())F. [100%]
=================================== FAILURES ===================================
___________________________________ test_add ___________________________________
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
> assert cart._items == [("pen", 15.0, 2)]
^^^^^^^^^^^
E AttributeError: 'Cart' object has no attribute '_items'
test_cart.py:7: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_add - AttributeError: 'Cart' object has no attribut...
1 failed, 1 passed in 0.01sUn test rosso, e nessun bug. Il test non controllava ciò che il carrello fa, ma come il carrello era costruito. Un test così fallisce quando migliori il codice e resta silenzioso quando lo rompi — l'esatto contrario del suo compito.
Quest'ultimo capitolo non parla di un altro strumento. Parla di giudizio: com'è fatto un buon test, come lasciare che i test guidino il codice, come lasciare che sia il computer a inventare casi di test per te, e come impedire che i test falliscano a caso.
Alla fine di questo capitolo sarai in grado di
- Pianificare un test prima di scriverlo: il contratto, i casi, e cosa lasciare fuori
- Strutturare un test come Arrange, Act, Assert, e dargli un nome che sia una frase
- Distinguere un test del comportamento da un test dell'implementazione, e riscrivere il secondo nel primo
- Lavorare in cicli red-green-refactor, con un vero fallimento a ogni passo rosso
- Scrivere test property-based con Hypothesis e leggere un esempio fallito ridotto (shrunk)
- Trovare e correggere le quattro cause tipiche dei test flaky
- Trasformare ogni segnalazione di bug in un test di regressione
Prerequisiti: test asincroni, hook e plugin.
Prima di scrivere il test
La maggior parte dei test cattivi non è scritta male. È pianificata male — scritta prima che qualcuno decidesse cosa promette il codice. Quindi, prima di qualsiasi riga di codice di test, quattro domande.
Per tutto questo capitolo costruiremo una piccola funzione seguendo il metodo test-first: slugify, che trasforma un titolo di articolo come "Café au lait" nel pezzo di URL "cafe-au-lait".
1. Qual è il contratto? Dillo in una o due frasi, così come lo vede il chiamante: dato un titolo (una `str`), restituisci uno slug: solo lettere ASCII minuscole e cifre, parole unite da un singolo trattino, nessun trattino a nessuna delle due estremità. Le lettere accentate diventano la loro lettera semplice. Nessun effetto collaterale — non tocca file, orologio o rete. Nota cosa il contratto non menziona: le espressioni regolari, la normalizzazione Unicode, le funzioni di supporto. Sono il come, e possono cambiare liberamente.
2. Cosa deve essere pronto? Un ambiente virtuale con gli strumenti installati, e il codice importabile dai test:
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install pytest hypothesisPoi due file uno accanto all'altro — slugs.py e test_slugs.py — ed esegui pytest da quella cartella, così from slugs import slugify funziona. Questo argomento non richiede fixture, file temporanei né variabili d'ambiente. Anche questo vale la pena notarlo: una funzione pura è la cosa più facile del mondo da testare, ed è un buon motivo per spingere la logica dentro funzioni pure.
3. Quali casi? Prima il percorso felice, poi i casi limite, poi l'input non valido, poi i confini:
| Caso | Input | Atteso | |---|---|---| | parole normali | "Hello World" | "hello-world" | | punteggiatura | "Hello, World!" | "hello-world" | | lettere accentate | "Café au lait" | "cafe-au-lait" | | spazi extra agli estremi | " Hello World " | "hello-world" | | le cifre si mantengono | "Top 10 Tips" | "top-10-tips" | | niente di utilizzabile | "!!!" | ? |
Quel punto interrogativo è la cella più utile della tabella. Scrivere il piano ha fatto emergere una decisione che nessuno ha ancora preso: cosa dovrebbe succedere quando un titolo non contiene alcuna lettera? Lo lasceremo aperto di proposito, e più avanti in questo capitolo vedremo quanto costa una domanda aperta.
4. Cosa non testerai? Il modulo re di Python, unicodedata e str.lower() hanno già i loro test; ritestarli ti rallenterebbe e basta. Le funzioni di supporto private (tutto ciò che inizia con _) si raggiungono attraverso slugify, mai direttamente. E non proverai a elencare a mano ogni carattere Unicode — è compito di un test property-based, più avanti in questo capitolo.
La tabella diventa i test. Tutto quello che segue implementa il piano.
Arrange, Act, Assert
Ogni buon test ha le stesse tre parti, nello stesso ordine:
- Arrange — costruisci il mondo di cui il test ha bisogno
- Act — esegui l'unica cosa che viene testata
- Assert — controlla cosa è uscito
Ecco il carrello testato di nuovo — questa volta attraverso ciò che promette, non attraverso come conserva le cose:
from cart import Cart
def test_an_empty_cart_totals_zero():
cart = Cart()
assert cart.total() == 0
def test_total_is_price_times_quantity_summed_over_items():
# Arrange
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
# Act
total = cart.total()
# Assert
assert total == 880.0
def test_adding_the_same_item_twice_adds_up_the_quantity():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("pen", 15.0, 1)
assert cart.total() == 45.0Con la lista del carrello, e poi di nuovo con quello basato sul dizionario:
... [100%]
3 passed in 0.01s
... [100%]
3 passed in 0.01sStessi test, due implementazioni, tutti verdi in entrambi i casi. È la proprietà che vuoi: un refactoring che mantiene il comportamento dovrebbe mantenere i test verdi. I commenti nel test di mezzo servono solo a mostrare la forma; la maggior parte delle persone li toglie e separa le tre parti con righe vuote, come nel terzo test.
Perché conta che l'Act sia un singolo passo? Perché quando il test fallisce, vuoi sapere quale cosa si è rotta. Se l'Act sono cinque chiamate, un fallimento indica cinque sospettati.
Testare il comportamento, non l'implementazione
La regola pratica: un test può usare solo ciò che un chiamante può usare. Per il carrello sono Cart(), .add() e .total(). Non _items, non _lines. Il trattino basso iniziale è il modo in cui Python dice "questo è mio, e posso cambiarlo".
Il modo sbagliato accoppia il test a una decisione che non è mai stata parte del contratto — che gli articoli siano conservati in una lista. Il modo giusto pone la domanda che farebbe un chiamante: se aggiungo queste cose, qual è il totale?
Due segnali che un test è accoppiato all'implementazione:
- Legge attributi che iniziano con
_, oppure fa il mock di una funzione che il codice chiama internamente - Un refactoring che nessun utente noterebbe lo fa diventare rosso
Una nota sui mock. I mock del capitolo undici sono lo strumento giusto a un confine — la rete, l'orologio, un servizio di pagamento. Usati dentro il tuo stesso codice ("verifica che _calculate sia stata chiamata con questi argomenti"), diventano test di implementazione con un altro nome.
Nomi che si leggono come frasi, e una sola ragione per fallire
Esegui i tre test del carrello con -v:
test_cart_behaviour.py::test_an_empty_cart_totals_zero PASSED [ 33%]
test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items PASSED [ 66%]
test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity PASSED [100%]Leggi solo i nomi e hai una specifica del carrello. test_add e test_total ti dicevano solo quale metodo era stato toccato. Un buon nome dice la situazione e il risultato atteso: un carrello vuoto totalizza zero. È lungo; va bene così. Non lo digiti mai, e lo leggi ogni volta che fallisce.
La seconda metà della regola è una sola ragione per fallire. Ecco l'opposto — un test che controlla tutto, eseguito contro un carrello con un bug (ignora quantity):
from cart import Cart
def test_cart():
cart = Cart()
assert cart.total() == 0
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0
cart.add("pen", 15.0, 1)
assert cart.total() == 895.0$ pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_one_big.py::test_cart - assert 865.0 == 880.0
1 failed in 0.01s(--tb=no nasconde i traceback e lascia solo il riassunto — la parte che leggi per prima.) test_cart è fallito — il che non dice nulla — con 865.0 == 880.0, che ora devi decifrare. E la terza asserzione non è mai stata eseguita, quindi non sai se sarebbe passata. Lo stesso bug contro i tre test mirati:
$ pytest -q --tb=no
.FF [100%]
=========================== short test summary info ============================
FAILED test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items
FAILED test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity
2 failed, 1 passed in 0.01sPrima di aprire un singolo traceback, il riassunto si legge già come una diagnosi: il carrello vuoto va bene, ma le quantità sono sbagliate. "Una sola ragione per fallire" non significa una sola riga assert — due asserzioni che controllano due lati dello stesso risultato vanno bene. Significa un solo comportamento per test.
La piramide dei test
I test vengono in dimensioni diverse:
- Test unitari controllano una funzione o una classe senza nulla di reale intorno. Millisecondi ciascuno. Ne hai migliaia.
- Test di integrazione controllano che i pezzi si incastrino: il tuo codice contro un database vero, un vero file system, una vera app HTTP tramite un client di test. Sono più lenti; ne hai decine o centinaia.
- Test end-to-end guidano l'intero sistema come farebbe un utente — un browser, un'API distribuita. Sono lenti e fragili; ne hai una manciata, a copertura dei percorsi che generano denaro.
Contati per numero, formano una piramide: base larga di test unitari, cima sottile di test end-to-end. Il motivo è il costo. Quando un test unitario fallisce, indica poche righe. Quando fallisce un test end-to-end, indica l'intero sistema. Spingi ogni controllo il più in basso possibile nella piramide, e riserva la cima a ciò che solo la cima può vedere.
Red, green, refactor
Il test-driven development (TDD) procede in un ciclo stretto:
- Red — scrivi un piccolo test per un comportamento che non esiste ancora, e guardalo fallire
- Green — scrivi il minimo codice che lo fa passare
- Refactor — sistema il codice, con i test verdi per tutto il tempo
Perché guardarlo fallire? Perché un test che non hai mai visto fallire potrebbe non testare nulla. Il passo rosso dimostra che il test sa accorgersi dell'assenza della funzionalità.
Prendiamo il piano dalla tabella, una riga alla volta.
Red. La prima riga della tabella, prima che slugs.py esista:
from slugs import slugify
def test_lowercases_and_joins_words_with_hyphens():
assert slugify("Hello World") == "hello-world"==================================== ERRORS ====================================
________________________ ERROR collecting test_slugs.py ________________________
ImportError while importing test module '/home/you/blog/test_slugs.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_slugs.py:1: in <module>
from slugs import slugify
E ModuleNotFoundError: No module named 'slugs'
=========================== short test summary info ============================
ERROR test_slugs.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sÈ un red perfettamente valido. Il test chiede qualcosa che non c'è.
Green. Il minimo codice che passa — ed è davvero il minimo:
def slugify(title):
return title.lower().replace(" ", "-"). [100%]
1 passed in 0.01sSembra barare. È voluto: il codice fa solo ciò che un test ha richiesto finora. Qualsiasi cosa in più sarebbe codice che nessun test controlla.
Red. La seconda riga della tabella:
def test_drops_punctuation():
assert slugify("Hello, World!") == "hello-world".F [100%]
=================================== FAILURES ===================================
____________________________ test_drops_punctuation ____________________________
def test_drops_punctuation():
> assert slugify("Hello, World!") == "hello-world"
E AssertionError: assert 'hello,-world!' == 'hello-world'
E
E - hello-world
E + hello,-world!
E ? + +
test_slugs.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_drops_punctuation - AssertionError: assert 'hello,...
1 failed, 1 passed in 0.01sIl ? indica esattamente i due caratteri che non dovrebbero esserci.
Green. Ora il trucco di sostituire un solo spazio non basta. Sostituisci ogni sequenza di "non lettera né cifra" con un solo trattino, poi togli i trattini dagli estremi:
import re
def slugify(title):
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-").. [100%]
2 passed in 0.01sLe righe per gli spazi extra e le cifre passerebbero già con questo codice. Aggiungile comunque — fanno parte del contratto — ma sappi che un test verde già alla prima esecuzione non ha dimostrato nulla sul codice che hai appena scritto. Solo un test rosso-poi-verde lo fa.
Red. Lettere accentate:
def test_turns_accented_letters_into_plain_ones():
assert slugify("Café au lait") == "cafe-au-lait"..F [100%]
=================================== FAILURES ===================================
_________________ test_turns_accented_letters_into_plain_ones __________________
def test_turns_accented_letters_into_plain_ones():
> assert slugify("Café au lait") == "cafe-au-lait"
E AssertionError: assert 'caf-au-lait' == 'cafe-au-lait'
E
E - cafe-au-lait
E ? -
E + caf-au-lait
test_slugs.py:13: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_turns_accented_letters_into_plain_ones - Assertion...
1 failed, 2 passed in 0.01sé non è in a-z, quindi è stata buttata via insieme alla punteggiatura.
Green. La normalizzazione Unicode "NFKD" scompone é in e più un segno diacritico separato; la codifica in ASCII con "ignore" elimina il segno e tiene la e:
import re
import unicodedata
def slugify(title):
plain = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", "-", plain.lower()).strip("-")... [100%]
3 passed in 0.01sRefactor. Funziona, ma il corpo è una riga densa. Dai un nome a ogni passo, compila l'espressione regolare una volta sola e aggiungi i type hint — senza cambiare alcun comportamento:
import re
import unicodedata
NOT_ALLOWED = re.compile(r"[^a-z0-9]+")
def _to_ascii(text: str) -> str:
"""Split accented letters into letter + accent, then drop the accents."""
decomposed = unicodedata.normalize("NFKD", text)
return decomposed.encode("ascii", "ignore").decode("ascii")
def slugify(title: str) -> str:
words = NOT_ALLOWED.sub("-", _to_ascii(title).lower())
return words.strip("-")test_slugs.py::test_lowercases_and_joins_words_with_hyphens PASSED [ 33%]
test_slugs.py::test_drops_punctuation PASSED [ 66%]
test_slugs.py::test_turns_accented_letters_into_plain_ones PASSED [100%]
============================== 3 passed in 0.01s ===============================È il passo che fa davvero valere il TDD. Potevi ristrutturare liberamente perché i test controllano il comportamento; se avessero messo le mani dentro _to_ascii o nell'espressione regolare, il refactoring li avrebbe rotti.
Property-based testing con Hypothesis
Ogni test visto finora ha un esempio inventato: "Hello World", "Café au lait". Gli esempi valgono quanto la tua immaginazione, e la tua immaginazione condivide i suoi punti ciechi con il codice che hai appena scritto.
Una proprietà è un'affermazione che vale per ogni input. Hypothesis genera gli input — cento per test, per default — e cerca con impegno di trovarne uno che la smentisca. @given dice da dove vengono gli input; strategies (sempre importato come st) descrive la loro forma:
from hypothesis import given
from hypothesis import strategies as st
@given(
prices=st.lists(st.integers(min_value=0, max_value=10_000), max_size=20),
discount=st.integers(min_value=0, max_value=100),
)
def test_a_discount_never_raises_the_total(prices, discount):
total = sum(prices)
discounted = total * (100 - discount) // 100
assert 0 <= discounted <= total. [100%]
1 passed in 0.01sUn punto solo, ma sono passate cento liste di prezzi e cento sconti. Altre strategie che userai: st.text(), st.floats(), st.booleans(), st.sampled_from([...]), st.dictionaries(...), st.builds(...).
Quali proprietà ha slugify? Non puoi dire quale sia lo slug di una stringa casuale, ma puoi dire come deve apparire, e che uno slug, trasformato di nuovo, resta uguale:
import re
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
@given(st.text())
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*|", slug)
@given(st.text())
def test_slugifying_a_slug_changes_nothing(title):
once = slugify(title)
assert slugify(once) == once.. [100%]
2 passed in 0.01sst.text() produce emoji, caratteri cinesi, caratteri di controllo, stringhe vuote — input che non avresti mai scritto nella tabella del piano.
Un round trip trova un bug vero
La proprietà più produttiva è il round trip: se puoi codificare qualcosa e poi decodificarlo, decodificare la codifica deve restituire l'originale. Ecco un encoder a run-length — "aaab" diventa "3a1b" — con due test di esempio che passano:
import re
def encode(text: str) -> str:
"""'aaab' -> '3a1b': each run of a character becomes count + character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
return "".join(char * int(count) for count, char in re.findall(r"(\d+)(\D)", encoded))from hypothesis import given
from hypothesis import strategies as st
from rle import decode, encode
def test_encode_counts_each_run():
assert encode("aaab") == "3a1b"
def test_decode_expands_each_run():
assert decode("3a1b") == "aaab"
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text..F [100%]
=================================== FAILURES ===================================
______________ test_decoding_an_encoding_gives_back_the_original _______________
@given(st.text())
> def test_decoding_an_encoding_gives_back_the_original(text):
^^^
test_rle.py:16:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
text = '0'
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
> assert decode(encode(text)) == text
E AssertionError: assert '' == '0'
E
E - 0
E Failing test case: test_decoding_an_encoding_gives_back_the_original(
E text='0',
E )
test_rle.py:17: AssertionError
=========================== short test summary info ============================
FAILED test_rle.py::test_decoding_an_encoding_gives_back_the_original - Asser...
1 failed, 2 passed in 0.01sIl testo "0" si codifica in "10" — "uno zero" — e il decoder legge 10 come un conteggio senza un carattere dopo. Qualsiasi testo che contenga una cifra viene corrotto. Entrambi i test di esempio usavano solo lettere, quindi non avrebbero mai potuto vederlo.
Shrinking
Hypothesis non ha trovato "0" per primo. Ha trovato una stringa lunga e brutta, poi l'ha ridotta (shrinking): ha provato input più piccoli e semplici, tenendo ogni volta quello che falliva ancora, finché nessuno più semplice falliva. Puoi vederlo succedere registrando ogni input fallito:
from hypothesis import given, seed, settings
from hypothesis import strategies as st
from rle import decode, encode
failing = []
@seed(2026)
@settings(database=None)
@given(st.text())
def round_trip(text):
if decode(encode(text)) != text:
if text not in failing:
failing.append(text)
raise AssertionError
try:
round_trip()
except AssertionError:
pass
for text in failing:
print(repr(text))'wfê\x9f\x0c\U000b6082Ñ/\x879\x9dÂ\n'
'\x03\U000a1acb\U0003c4fc1'
'´\U000cd3dc\U00081acfÔ÷\U0007122f\x89ê½5'
'l𨺮\nÝÛ\x894\x8b'
'\U00083189𤾨«ñï\xa0^ó8A\x96\x88=\x05'
'0000'
'000'
'00'
'0'(@seed fissa le scelte casuali così che l'esecuzione sia ripetibile; database=None impedisce a Hypothesis di riprodurre un fallimento ricordato.) Il primo fallimento sono tredici caratteri di rumore con un 9 nascosto dentro. Ci avresti messo minuti a fissarlo. L'esempio ridotto racconta tutta la storia con un solo carattere: una cifra lo rompe.
Hypothesis salva anche gli esempi falliti in una cartella .hypothesis/, quindi la volta successiva prova prima "0". Aggiungi quella cartella a .gitignore.
La correzione mette un separatore tra conteggio e carattere, così una cifra non può mai essere scambiata per parte di un conteggio. E l'esempio trovato da Hypothesis viene fissato con @example, così gira ogni volta, su ogni macchina:
import re
def encode(text: str) -> str:
"""'aaab' -> '3:a1:b': each run becomes count, a colon, then the character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}:{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
pairs = re.findall(r"(\d+):(.)", encoded, flags=re.DOTALL)
return "".join(char * int(count) for count, char in pairs)I due test di esempio cambiano al nuovo formato ("3:a1:b"), e la proprietà guadagna una riga:
from hypothesis import example, given
from hypothesis import strategies as st
from rle import decode, encode
@given(st.text())
@example("0") # the input Hypothesis found; now it is checked on every run
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text... [100%]
3 passed in 0.01sLe proprietà non sostituiscono gli esempi. I due test di esempio documentano il formato in un modo che un lettore capisce a colpo d'occhio; la proprietà protegge gli angoli a cui nessuno ha pensato. Usali entrambi.
Test flaky
Un test flaky passa e fallisce con lo stesso codice. È peggio di non avere test: la gente impara a premere "riesegui" e smette di credere al rosso. Quasi tutti i test flaky nascono da una di quattro cause, e ognuna ha una correzione che elimina la causa invece di nasconderla.
Stato condiviso e ordine dei test
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)from registry import count, register
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0$ pytest -q --tb=no
.F [100%]
=========================== short test summary info ============================
FAILED test_registry.py::test_a_new_registry_is_empty - assert 1 == 0
1 failed, 1 passed in 0.01s
$ pytest -q "test_registry.py::test_a_new_registry_is_empty"
. [100%]
1 passed in 0.01sFallisce nella suite, passa da solo. Il set a livello di modulo sopravvive da un test all'altro, quindi il secondo test vede ciò che ha lasciato il primo. Cambia l'ordine, oppure esegui i test in parallelo con pytest-xdist, e il risultato cambia. La correzione sbagliata è riordinare i test. La correzione giusta elimina lo stato condiviso: fai del registro un oggetto, e dai a ogni test un registro nuovo tramite una fixture:
class Registry:
def __init__(self):
self._users = set()
def register(self, name):
self._users.add(name)
def count(self):
return len(self._users)import pytest
from registry import Registry
@pytest.fixture
def registry():
return Registry() # a fresh one for every test
def test_registering_a_user_counts_them(registry):
registry.register("rahim")
assert registry.count() == 1
def test_a_new_registry_is_empty(registry):
assert registry.count() == 0.. [100%]
2 passed in 0.01sQuando non puoi cambiare il codice, una fixture autouse che reimposta lo stato prima e dopo ogni test è la soluzione di ripiego. Ogni test dovrebbe passare da solo, e in qualsiasi ordine.
Il tempo
from datetime import datetime
def greeting(now=None):
now = now or datetime.now()
return "Good morning" if now.hour < 12 else "Good afternoon"from greeting import greeting
def test_greets_the_morning():
assert greeting() == "Good morning" # true only before noonLo stesso codice, eseguito nello stesso momento, su due macchine impostate con fusi orari diversi:
$ TZ=Europe/London pytest -q
. [100%]
1 passed in 0.01s
$ TZ=Asia/Dhaka pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_greeting.py::test_greets_the_morning - AssertionError: assert 'Go...
1 failed in 0.01sLa correzione è già nella firma della funzione: now può essere passato dall'esterno. Un test che controlla l'orologio verifica entrambi i lati del confine, e dà la stessa risposta a qualsiasi ora:
from datetime import datetime
from greeting import greeting
def test_before_noon_it_says_good_morning():
assert greeting(now=datetime(2026, 1, 5, 9, 30)) == "Good morning"
def test_from_noon_on_it_says_good_afternoon():
assert greeting(now=datetime(2026, 1, 5, 12, 0)) == "Good afternoon".. [100%]
2 passed in 0.01sPassare l'ora dall'esterno è più semplice di qualsiasi mock. Quando non puoi cambiare la firma, monkeypatch del capitolo dieci sostituisce invece l'orologio.
La casualità
import random
def pick_winner(names, rng=random):
return rng.choice(names)from raffle import pick_winner
def test_picks_a_winner():
assert pick_winner(["rahim", "karim", "salma"]) == "rahim"Sei esecuzioni, nessuna modifica a nulla:
$ for i in 1 2 3 4 5 6; do pytest -q | tail -1; done
1 failed in 0.01s
1 failed in 0.01s
1 failed in 0.01s
1 passed in 0.01s
1 passed in 0.01s
1 passed in 0.01sDue correzioni, per due tipi di domanda. Asserisci ciò che è vero per ogni esito — il vincitore è uno dei partecipanti. Oppure prendi il controllo della casualità passando un generatore con seme:
import random
from raffle import pick_winner
def test_the_winner_is_one_of_the_entrants():
names = ["rahim", "karim", "salma"]
assert pick_winner(names) in names
def test_the_same_seed_picks_the_same_winner():
names = ["rahim", "karim", "salma"]
first = pick_winner(names, rng=random.Random(42))
second = pick_winner(names, rng=random.Random(42))
assert first == second$ for i in 1 2 3; do pytest -q | tail -1; done
2 passed in 0.01s
2 passed in 0.01s
2 passed in 0.01sIl mondo esterno
La quarta causa è tutto ciò che non controlli: una vera chiamata di rete, un vero server, un sleep(0.1) che "dovrebbe bastare". Le correzioni sono quelle dei capitoli precedenti — fai il mock al confine, usa tmp_path invece di una cartella condivisa, aspetta una condizione invece di un tempo fisso. Una sola forma copre tutte e quattro le cause: tutto ciò da cui il test dipende, il test dovrebbe crearlo o controllarlo.
Un test di regressione per ogni bug
Torniamo al punto interrogativo della tabella del piano. Nessuno ha risposto, e arriva la segnalazione di bug: un articolo intitolato `"!!!"` è stato salvato in `/posts/`, sovrascrivendo la pagina indice.
Prima di toccare slugify, scrivi un test che riproduce la segnalazione e guardalo fallire. Questo dimostra che il test cattura questo bug:
import pytest
from slugs import slugify
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
with pytest.raises(ValueError, match="no letters or digits"):
slugify("!!!")$ pytest -q test_slugs_regressions.py
F [100%]
=================================== FAILURES ===================================
______________ test_a_title_with_no_letters_or_digits_is_rejected ______________
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
> with pytest.raises(ValueError, match="no letters or digits"):
E Failed: DID NOT RAISE ValueError
test_slugs_regressions.py:8: Failed
=========================== short test summary info ============================
FAILED test_slugs_regressions.py::test_a_title_with_no_letters_or_digits_is_rejected
1 failed in 0.01sPoi correggilo — la fine di slugify diventa:
def slugify(title: str) -> str:
slug = NOT_ALLOWED.sub("-", _to_ascii(title).lower()).strip("-")
if not slug:
raise ValueError(f"title has no letters or digits: {title!r}")
return slug.... [100%]
4 passed in 0.01sIl test di regressione e i tre test di esempio passano. Ma esegui l'intera suite, e i test property-based protestano:
FAILED test_slugs_properties.py::test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens
FAILED test_slugs_properties.py::test_slugifying_a_slug_changes_nothing - Val...
2 failed, 4 passed in 0.01sHypothesis ha provato la stringa vuota, e slugify("") ora solleva un'eccezione. Non è un bug nuovo — è il contratto che cambia di proposito, e le proprietà se ne accorgono. Bene. Aggiornale perché dichiarino il nuovo contratto: titoli che contengono almeno una lettera o una cifra.
import re
import string
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
# Any text, with at least one plain letter or digit somewhere inside it.
titles = st.builds(
lambda before, word, after: before + word + after,
st.text(),
st.text(alphabet=string.ascii_letters + string.digits, min_size=1),
st.text(),
)
@given(titles)
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", slug)test_slugifying_a_slug_changes_nothing passa a @given(titles) allo stesso modo.
...... [100%]
6 passed in 0.01sPerché un test per ogni bug? Perché un bug che è successo una volta ha dimostrato quanto sia facile commettere quell'errore, e la prossima persona che tocca quel codice probabilmente lo ripeterà. Il commento che spiega cosa è andato storto è parte del test: tra un anno, sarà l'unica traccia del perché questo caso strano è lì.
Un esempio completo
Un controllore della robustezza delle password, costruito con tutto ciò che è in questo capitolo. Prima il piano:
| Caso | Input | Atteso | |---|---|---| | vuota | "" | weak | | ogni tipo, troppo corta | "Ab1!xyz" (7) | weak | | abbastanza lunga, un tipo | "abcdefgh" | weak | | abbastanza lunga, due tipi | "abcdefg1" | medium | | 12 di lunghezza, tre tipi | "abcdefghij1!" | strong | | 12 di lunghezza, due tipi | "abcdefghijk1" | medium |
passwords.py:
def _kinds(password: str) -> int:
"""How many of the four kinds of character the password uses."""
return sum([
any(c.islower() for c in password),
any(c.isupper() for c in password),
any(c.isdigit() for c in password),
any(not c.isalnum() for c in password),
])
def strength(password: str) -> str:
"""Rate a password as 'weak', 'medium' or 'strong'."""
if len(password) < 8:
return "weak"
kinds = _kinds(password)
if len(password) >= 12 and kinds >= 3:
return "strong"
if kinds >= 2:
return "medium"
return "weak"test_passwords.py:
import pytest
from hypothesis import given
from hypothesis import strategies as st
from passwords import strength
RANK = {"weak": 0, "medium": 1, "strong": 2}
@pytest.mark.parametrize(
"password, expected",
[
("", "weak"), # nothing at all
("Ab1!xyz", "weak"), # every kind, but only 7 long
("abcdefgh", "weak"), # 8 long, one kind
("abcdefg1", "medium"), # 8 long, two kinds
("abcdefghij1!", "strong"), # 12 long, three kinds
("abcdefghijk1", "medium"), # 12 long, only two kinds
],
)
def test_strength_follows_the_length_and_variety_rules(password, expected):
assert strength(password) == expected
@given(st.text(), st.text())
def test_adding_characters_never_makes_a_password_weaker(password, extra):
before = strength(password)
after = strength(password + extra)
assert RANK[after] >= RANK[before]
def test_a_long_password_of_one_kind_is_still_weak():
# Bug: "aaaaaaaaaaaaaaaaaaaa" (20 letters) was rated "medium"
assert strength("a" * 20) == "weak"$ pytest -v
collected 8 items
test_passwords.py::test_strength_follows_the_length_and_variety_rules[-weak] PASSED [ 12%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[Ab1!xyz-weak] PASSED [ 25%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefgh-weak] PASSED [ 37%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefg1-medium] PASSED [ 50%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghij1!-strong] PASSED [ 62%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghijk1-medium] PASSED [ 75%]
test_passwords.py::test_adding_characters_never_makes_a_password_weaker PASSED [ 87%]
test_passwords.py::test_a_long_password_of_one_kind_is_still_weak PASSED [100%]
============================== 8 passed in 0.01s ===============================Tre decisioni meritano attenzione.
Le righe della tabella stanno sui confini — 7 e 8 caratteri, 11 e 12, due tipi e tre. I bug vivono ai confini: un < che dovrebbe essere <= è invisibile in mezzo a un intervallo.
La proprietà dice qualcosa che nessun esempio può dire: aggiungere caratteri non rende mai una password più debole. Nessuno scriverebbe a mano quel test per ogni password, e Hypothesis lo verifica su centinaia di casi, incluse le lettere Unicode che non sono né maiuscole né minuscole.
E nessun test tocca _kinds. Si raggiunge attraverso strength; domani potrebbe non esistere più.
Quando si rompe
AttributeError: 'Cart' object has no attribute '_items' dopo un refactoring Il test stava leggendo l'interno dell'oggetto. Riscrivilo perché controlli attraverso i metodi pubblici — ciò che un chiamante può vedere — e sopravviverà anche al prossimo refactoring.
Un test passa da solo ma fallisce nell'esecuzione completa (o viceversa) Stato condiviso: una lista, un dizionario o una cache a livello di modulo; un file in una posizione fissa; una variabile d'ambiente impostata e mai rimossa. Fai in modo che ogni test crei ciò di cui ha bisogno (fixture, tmp_path, monkeypatch) invece di riordinare i test.
hypothesis.errors.FailedHealthCheck: It looks like this test is filtering out a lot of inputs. 0 inputs were generated successfully, while 50 inputs were filtered out. Un .filter() (o assume()) butta via quasi tutto ciò che Hypothesis genera — per esempio st.integers().filter(lambda n: n % 1000 == 7). Costruisci direttamente i valori che vuoi: st.integers().map(lambda n: n * 1000 + 7).
hypothesis.errors.FlakyFailure: Hypothesis test_depends_on_earlier_runs(n=-25617) produces unreliable results: Failed on the first call but did not on a subsequent one Il test ha dato una risposta diversa per lo stesso input. Qualcosa fuori dagli argomenti generati è cambiato tra una chiamata e l'altra — un contatore, una lista a livello di modulo, l'orologio. Hypothesis riproduce ogni fallimento, quindi un test con stato nascosto non può essere ridotto.
hypothesis.errors.DeadlineExceeded: Test took 300.07ms, which exceeds the deadline of 200.00ms. Ogni esempio generato deve finire entro 200 ms per default, perché cento esempi lenti rendono lenta la suite. Rendi il test più veloce, oppure, se la lentezza è reale, alza il limite con @settings(deadline=...).
Un nuovo test TDD passa già alla prima esecuzione Non sta dimostrando nulla sul codice che stai per scrivere. Il comportamento o esiste già (va bene — tieni il test come documentazione), oppure il test non controlla ciò che credi. Rompi il codice di proposito per un momento e verifica che il test diventi rosso.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Eseguito come file intero, il secondo test fallisce. Qual è l'ultima riga quando si esegue da solo il secondo test?
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0
# Run only the second test:
# pytest -q "test_registry.py::test_a_new_registry_is_empty"- A1 failed, 1 passed in 0.01s
- B1 failed in 0.01s
- C1 error in 0.01s
- D1 passed in 0.01s
Cart passa da una lista a un dizionario internamente, e il suo comportamento resta lo stesso. Quale test diventa rosso?
def test_total_of_two_pens():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart.total() == 30.0
def test_empty_cart_totals_zero():
assert Cart().total() == 0
def test_one_line_per_item():
cart = Cart()
cart.add("pen", 15.0)
assert len(cart._items) == 1
def test_adding_twice_adds_up():
cart = Cart()
cart.add("pen", 15.0)
cart.add("pen", 15.0)
assert cart.total() == 30.0- A`test_empty_cart_totals_zero`
- B`test_one_line_per_item`
- C`test_total_of_two_pens`
- D`test_adding_twice_adds_up`
Questo test passa dal lunedì al venerdì e fallisce il sabato e la domenica. Qual è la correzione migliore?
from datetime import date
def is_weekend(day=None):
day = day or date.today()
return day.weekday() >= 5
def test_weekdays_are_not_weekend():
assert is_weekend() is False- APassa una data fissa — `is_weekend(date(2026, 10, 7))` — e aggiungi un test separato per un sabato
- BScrivi `== False` invece di `is False`
- CRiesegui automaticamente il test ogni volta che fallisce
- DSalta il test il sabato e la domenica
Per rispondere serve un account
Accedi per verificare le tue risposte
Le domande sono qui sopra, e ragionarci a mente è la parte che conta. Accedi per vedere le risposte, le spiegazioni e i suggerimenti a tre livelli.
Tocca a te
Costruisci due funzioni, test-first, in durations.py:
parse_duration(text)trasforma"1h30m"in5400(secondi). Le unità sonoh,m,s, in questo ordine, ognuna facoltativa;"45s","2h"e"1h0m5s"sono tutti validi. Qualsiasi altra cosa —"","h","1x","30m1h","1.5h"— sollevaValueErrorconnot a durationnel messaggio.format_duration(seconds)fa il percorso inverso:5400diventa"1h30m",3605diventa"1h5s",0diventa"0s". Un numero negativo sollevaValueError.
Lavora in questo ordine:
- Scrivi per primo la tabella del piano: caso, input, atteso — inclusi gli input non validi e i confini (
59e60secondi, per esempio). - Procedi riga per riga in cicli red-green. Esegui
pytestdopo ogni modifica e guarda il rosso prima di renderlo verde. - Aggiungi la proprietà di round trip con Hypothesis: per ogni numero intero di secondi da
0in su,parse_duration(format_duration(n)) == n. - Un utente segnala che
"90m"è stato rifiutato, anche se la gente lo digita continuamente. Aggiungi un test di regressione con un commento che spiega cosa è andato storto.
Quando hai finito, fai un refactoring una volta, e verifica che l'intera suite resti verde.
Soluzione
durations.py:
import re
UNITS = {"h": 3600, "m": 60, "s": 1}
PATTERN = re.compile(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?")
def parse_duration(text: str) -> int:
"""'1h30m' -> 5400. Units must appear in the order h, m, s."""
match = PATTERN.fullmatch(text)
if not text or match is None:
raise ValueError(f"not a duration: {text!r}")
hours, minutes, seconds = (int(part or 0) for part in match.groups())
return hours * 3600 + minutes * 60 + seconds
def format_duration(seconds: int) -> str:
"""5400 -> '1h30m'. Zero is '0s'."""
if seconds < 0:
raise ValueError(f"duration cannot be negative: {seconds}")
if seconds == 0:
return "0s"
parts = []
for unit, size in UNITS.items():
amount, seconds = divmod(seconds, size)
if amount:
parts.append(f"{amount}{unit}")
return "".join(parts)test_durations.py:
import pytest
from hypothesis import given
from hypothesis import strategies as st
from durations import format_duration, parse_duration
@pytest.mark.parametrize(
"text, seconds",
[
("45s", 45),
("2h", 7200),
("1h30m", 5400),
("1h0m5s", 3605),
("0s", 0),
],
)
def test_parse_reads_hours_minutes_and_seconds(text, seconds):
assert parse_duration(text) == seconds
@pytest.mark.parametrize("text", ["", "h", "1x", "30m1h", "1.5h"])
def test_parse_rejects_text_that_is_not_a_duration(text):
with pytest.raises(ValueError, match="not a duration"):
parse_duration(text)
@pytest.mark.parametrize(
"seconds, text",
[(0, "0s"), (59, "59s"), (60, "1m"), (3605, "1h5s"), (5400, "1h30m")],
)
def test_format_writes_only_the_units_it_needs(seconds, text):
assert format_duration(seconds) == text
def test_format_rejects_a_negative_duration():
with pytest.raises(ValueError, match="negative"):
format_duration(-1)
@given(st.integers(min_value=0, max_value=10**7))
def test_parsing_a_formatted_duration_gives_back_the_seconds(seconds):
assert parse_duration(format_duration(seconds)) == seconds
def test_minutes_over_sixty_are_accepted():
# Bug: "90m" was rejected, though people type it all the time
assert parse_duration("90m") == 5400$ pytest -q
.................. [100%]
18 passed in 0.01sPerché è costruito così:
- Il piano è diventato le tabelle di
parametrize. Ogni riga della tabella è una riga diparametrize, quindi aggiungere un caso in seguito richiede una sola riga, e i nomi dei test nell'output di-vmostrano quale riga è fallita. - Gli input validi e quelli non validi sono test separati. Sono comportamenti distinti con motivi distinti per fallire. La lista degli input non validi contiene un esempio per ogni tipo di errore: stringa vuota, unità senza numero, unità sconosciuta, ordine sbagliato, decimale.
match="not a duration"controlla anche il messaggio oltre al tipo, così unValueErrorsollevato per sbaglio altrove —int(""), per dire — non può far passare il test.if not textc'è perché il pattern, con ogni parte facoltativa, corrisponde alla stringa vuota. È esattamente il tipo di caso che la tabella del piano ti costringe a considerare prima che sia la regex a farlo per te.59e60sono il confine in cui i secondi passano ai minuti; un test in mezzo all'intervallo non catturerebbe mai un errore di un'unità lì.- La proprietà di round trip è il test più forte del file. Non conosce nemmeno un valore atteso, eppure confronta tra loro dieci milioni di durate possibili — ogni disaccordo tra le due funzioni emerge, ridotto al numero più piccolo che lo rompe.
- Il test di regressione passa già con questa implementazione; esiste per impedire che un futuro "riordino" che limita i minuti a
0-59riporti il bug. Il suo commento spiega il perché. - Nessun test controlla direttamente
PATTERNoUNITS. Sono implementazione, e sei libero di sostituire la regex con un parser scritto a mano domani.
Dove andare dopo
Questa è la fine del corso. Ora sai scrivere test, leggerne i fallimenti, strutturarli, isolarli, misurarli, estendere pytest stesso e — dopo questo capitolo — decidere cosa vale la pena testare. Alcune direzioni da qui:
- Falla diventare un'abitudine sul codice vero. Prendi un progetto che hai già e aggiungi test alla parte che hai più paura di cambiare. Scrivi prima la tabella del piano. La paura scende man mano che i test salgono.
- Vai oltre con Hypothesis. La sua documentazione copre il testing con stato (
RuleBasedStateMachine), che genera intere sequenze di operazioni su un oggetto, e trova bug che nessun singolo input potrebbe trovare. - Mutation testing. Strumenti come
mutmutfanno piccole modifiche al tuo codice —<in<=,+in-— e verificano che qualche test fallisca. Una mutazione che sopravvive è una riga che i tuoi test non controllano davvero, per quanto alto sia il numero di coverage. - Fai crescere la configurazione di CI del capitolo quattordici. Esegui la suite su ogni versione di Python che supporti, e fai in modo che un test fallito blocchi il merge — una suite che nessuno è obbligato a mantenere verde smette lentamente di essere verde.
- Leggi buone suite di test. I test di
pytest,requests,attrsehypothesisstessi sono open source. Leggere come persone esperte nominano, strutturano e isolano i test insegna più di qualsiasi elenco di regole.
Qualunque cosa scriverai dopo, parti dalla domanda con cui si apriva questo capitolo: se domani qualcuno migliora questo codice senza cambiare ciò che fa, i miei test resteranno verdi — e se qualcuno lo rompe, diventeranno rossi?
Passo 6 di 6
Sfida — il quiz del capitolo
Dieci domande dalla più facile alla più difficile. Le ultime sono difficili di proposito.
Accedi per fare il quiz