Testing con pytest
Da `assert` a pytest, leggere un report dei fallimenti, `pytest.raises`, `parametrize`, `tmp_path` e le fixture — e quale forma di codice è facile da testare.
Come vuoi imparare?
Il problema che stiamo risolvendo
Finora abbiamo "testato" il codice in un solo modo: lo eseguiamo, leggiamo l'output e decidiamo se sembra giusto.
Funziona una volta. Il problema comincia la seconda volta.
Il programma del capitolo 25 aveva TAX_RATE a 0.15. Supponiamo che diventi 0.18. Il programma gira, tutti i numeri vengono stampati, nulla va in errore — e non hai modo di sapere se qualcosa è cambiato, a meno di ricordarti l'output di prima.
E quello che alla fine andrebbe ricordato, non lo ricordi più.
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 1) == 17.25
print("ok")okassert è una frase semplice: "questo deve essere vero". Quando lo è, non succede niente; quando non lo è, il programma si ferma con un AssertionError.
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 3) == 51.0
print("ok")AssertionErrorQuesta è la base. Questo capitolo costruisce su di essa uno strumento — pytest — che esegue centinaia di queste affermazioni e, quando una fallisce, ti dice perché.
Alla fine di questo capitolo sarai in grado di
- Scrivere un file di test ed eseguire
pytest - Leggere un report dei fallimenti e capire il problema da lì
- Testare gli errori con
pytest.raises - Eseguire un test su molti casi con
@pytest.mark.parametrize - Testare il codice che lavora con i file usando
tmp_path - Dire quale codice è facile da testare, e perché
Prerequisiti: Dataclass e hint di tipo.
Un primo test
Due regole, e basta: il nome del file inizia con test_, e così il nome della funzione di test. (pytest cerca anche i file che finiscono con _test.py, ma in questo corso iniziamo i nomi con test_.)
pricing.py:
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)test_pricing.py:
from pricing import line_total
def test_one_item():
assert line_total(15.0, 1) == 17.25
def test_three_items():
assert line_total(15.0, 3) == 51.75Poi pytest -q:
.. [100%]
2 passed in 0.00sDue punti, due test superati. pytest trova i file, trova le funzioni e le esegue — non serve registrare nulla da nessuna parte. Il tempo alla fine (0.00s) sulla tua macchina può essere un po' diverso.
Il report dei fallimenti è ciò che conta
Metti un numero volutamente sbagliato nel secondo test di test_pricing.py — 51.0 invece di 51.75 — ed esegui di nuovo pytest -q:
.F [100%]
=================================== FAILURES ===================================
_______________________________ test_three_items _______________________________
def test_three_items():
> assert line_total(15.0, 3) == 51.0
E assert 51.75 == 51.0
E + where 51.75 = line_total(15.0, 3)
test_pricing.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_three_items - assert 51.75 == 51.0
1 failed, 1 passed in 0.01sIl .F sulla prima riga significa che il primo test è stato superato e il secondo è fallito. Un assert semplice ci avrebbe dato solo AssertionError — una parola, senza informazioni. Qui otteniamo:
- Quale test (
test_three_items) e quale riga (test_pricing.py:9) - L'
assertesatto che si è rotto, segnato con> assert 51.75 == 51.0— cosa è stato prodotto davvero, confrontato con quanto attesowhere 51.75 = line_total(15.0, 3)— da dove arriva quel numero- Il conteggio sull'ultima riga:
1 failed, 1 passed
Si chiama assertion introspection, ed è il motivo principale per usare pytest. Molti strumenti di test richiedono un metodo separato, come assertEqual(a, b), per questo; qui basta un normale ==.
Anche gli errori sono comportamento
Il capitolo 24 diceva che una funzione dovrebbe raise con un input non valido. Anche quel comportamento è una promessa, quindi può essere testato:
import pytest
from pricing import line_total
def test_zero_is_rejected():
with pytest.raises(ValueError):
line_total(15.0, 0)
def test_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3).. [100%]
2 passed in 0.01sIl blocco pytest.raises dice "questo errore deve succedere qui dentro". Se non succede, il test fallisce. Per esempio, se un test chiamato test_one_is_rejected si aspetta un ValueError da line_total(15.0, 1) — anche se una quantità di 1 è valida:
F [100%]
=================================== FAILURES ===================================
_____________________________ test_one_is_rejected _____________________________
def test_one_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_pricing.py:7: Failed
=========================== short test summary info ============================
FAILED test_pricing.py::test_one_is_rejected - Failed: DID NOT RAISE ValueError
1 failed in 0.01sLa parte match= controlla che il messaggio contenga quel testo. Vale la pena scriverla, perché la regola del capitolo 24 — metti il valore sbagliato nel messaggio — diventa così una promessa testata a sé.
Un test, molti casi
Scrivere quattro funzioni per quattro casi è noioso, e favorisce gli errori da copia-incolla.
import pytest
from pricing import line_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(100.0, 2, 230.0),
],
)
def test_line_total(price, quantity, expected):
assert line_total(price, quantity) == expectedCon pytest -v:
============================= test session starts ==============================
collecting ... collected 4 items
test_pricing.py::test_line_total[15.0-1-17.25] PASSED [ 25%]
test_pricing.py::test_line_total[15.0-3-51.75] PASSED [ 50%]
test_pricing.py::test_line_total[0.0-5-0.0] PASSED [ 75%]
test_pricing.py::test_line_total[100.0-2-230.0] PASSED [100%]
============================== 4 passed in 0.01s ===============================Quattro test separati nati da una sola funzione. Ogni nome contiene i propri valori, quindi quando uno fallisce vedi subito quale. Scritto come ciclo, il primo fallimento fermerebbe tutti gli altri, e non direbbe quali valori erano sbagliati.
Metti 51.0 al posto di 51.75 nel secondo caso ed esegui pytest -q; l'inizio del report mostra anche i valori:
.F.. [100%]
=================================== FAILURES ===================================
_________________________ test_line_total[15.0-3-51.0] _________________________
price = 15.0, quantity = 3, expected = 51.0Quando serve un file — tmp_path
Testare il codice che legge i file, come quello del capitolo 23, richiede un file. Tenerne uno nel repository è una cattiva abitudine: i test finiscono intrecciati tra loro, e un test che modifica il file rompe un altro.
Supponi che reader.py contenga questa funzione — restituisce le righe non vuote di un file di testo:
from pathlib import Path
def read_names(path: Path) -> list[str]:
"""Return the non-blank lines of a text file, without spaces at the ends."""
text = path.read_text(encoding="utf-8")
return [line.strip() for line in text.splitlines() if line.strip()]pytest può dare a ogni test una cartella vuota tutta sua. test_reader.py:
from reader import read_names
def test_blank_lines_are_skipped(tmp_path):
path = tmp_path / "names.txt"
path.write_text("one\n\ntwo\n", encoding="utf-8")
assert read_names(path) == ["one", "two"]
def test_empty_file_gives_empty_list(tmp_path):
path = tmp_path / "names.txt"
path.write_text("", encoding="utf-8")
assert read_names(path) == [].. [100%]
2 passed in 0.01stmp_path è un pathlib.Path — il Path del capitolo 23, unito con / esattamente come lì. Il nome del parametro dice a pytest cosa fornire. Ogni test riceve una cartella nuova, quindi due test possono usare lo stesso nome di file senza entrare in conflitto.
Setup ripetuto — fixture
Quando più test hanno bisogno dello stesso input, lo si può scrivere una volta sola e dargli un nome. Qui order_total è un'altra funzione di pricing.py che somma un intero ordine — il file completo si trova nell'esempio completo più avanti:
import pytest
from pricing import order_total
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1)]
def test_total(order):
assert order_total(order) == 1029.25
def test_one_line_removed(order):
assert order_total(order[:1]) == 51.75.. [100%]
2 passed in 0.01sCome con tmp_path, pytest abbina per nome del parametro. E la funzione viene rieseguita per ogni test, quindi anche se un test modifica la lista, il successivo ne riceve comunque una nuova — il problema dei dati condivisi del capitolo 21, eliminato per costruzione.
La trappola della virgola mobile
def test_addition():
assert 0.1 + 0.2 == 0.3F [100%]
=================================== FAILURES ===================================
________________________________ test_addition _________________________________
def test_addition():
> assert 0.1 + 0.2 == 0.3
E assert (0.1 + 0.2) == 0.3
test_money.py:2: AssertionError
=========================== short test summary info ============================
FAILED test_money.py::test_addition - assert (0.1 + 0.2) == 0.3
1 failed in 0.01sIl vecchio problema del capitolo 5, e i test sono spesso il punto in cui si manifesta per la prima volta.
import pytest
def test_addition():
assert 0.1 + 0.2 == pytest.approx(0.3). [100%]
1 passed in 0.00spytest.approx dice "basta che sia abbastanza vicino". Usalo in ogni test che coinvolge dei float — a meno che il numero non sia già stato fissato con round(), come fa line_total.
Cosa testare
Una regola vale più di tutte le altre: una funzione che riceve dei valori e restituisce un valore è facile da testare.
Ricordi le funzioni pure del capitolo 21? Questo capitolo mostra perché valgono la pena. Testare line_total non richiedeva né file, né input, né setup — solo chiamarla e guardare il risultato.
D'altra parte, una funzione che stampa, chiede un input o scrive un file ha bisogno di un setup extra prima di poter essere testata. Ecco perché report nel capitolo 23 non prendeva un file: riceveva una lista e restituiva una lista.
Se scrivere i test è difficile, di solito la colpa non è dei test, ma della forma del codice.
Cosa testare, in tre tipi: ciò che succede normalmente, i casi limite (zero, vuoto, uno) e ciò che dovrebbe fallire.
Un esempio completo
pricing.py:
"""Prices and tax. Pure functions, which is what makes them testable."""
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)
def order_total(lines: list[tuple[str, float, int]]) -> float:
return round(sum(line_total(price, qty) for _, price, qty in lines), 2)test_pricing.py:
"""Tests for pricing. Each one names the behaviour it protects."""
import pytest
from pricing import line_total, order_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(0.01, 1, 0.01),
],
)
def test_line_total_applies_tax(price, quantity, expected):
assert line_total(price, quantity) == expected
@pytest.mark.parametrize("quantity", [0, -1, -100])
def test_quantity_below_one_is_rejected(quantity):
with pytest.raises(ValueError, match="at least 1"):
line_total(15.0, quantity)
def test_error_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3)
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1), ("ink", 120.0, 2)]
def test_order_total_sums_the_lines(order):
assert order_total(order) == 1305.25
def test_empty_order_totals_zero():
assert order_total([]) == 0
def test_one_bad_line_stops_the_order(order):
with pytest.raises(ValueError):
order_total(order + [("clip", 5.0, 0)])........... [100%]
11 passed in 0.01sQuattro cose da notare.
Undici test da sei funzioni di test. parametrize ha fatto la differenza, e ogni caso viene contato separatamente.
I nomi descrivono il comportamento, non il codice. test_quantity_below_one_is_rejected ti dice cosa promette il programma; test_line_total_2 non ti dice nulla. Quel nome è la prima cosa che mostra un report dei fallimenti, quindi vale la pena farne una frase.
Il caso 0.01 non è scelto a caso. È il prezzo più piccolo possibile — un caso limite, e proprio il punto in cui il comportamento di round() è più incerto. I bug si nascondono ai margini, non al centro.
L'ultimo test contiene una promessa indiretta. order_total non fa alcuna validazione propria: si affida a line_total. Se un giorno qualcuno mettesse un try/except in order_total per saltare in silenzio le righe sbagliate, questo test fallirebbe e chiederebbe: "hai davvero voluto farlo?"
Quando si rompe
no tests ran Il nome del file non inizia con test_ (né finisce con _test.py), oppure il nome della funzione non inizia con test_. Servono entrambi.
ModuleNotFoundError: No module named 'pricing' Esegui pytest dalla cartella in cui si trovano i file.
Un test passa e non controlla nulla Manca l'assert. Chiamare soltanto una funzione passa finché non solleva un errore.
fixture 'order' not found Manca @pytest.fixture, oppure il nome non corrisponde a quello del parametro.
Un confronto tra float fallisce anche se i numeri sembrano giusti Usa pytest.approx, oppure round() dentro la funzione stessa.
DID NOT RAISE Il codice dentro il blocco pytest.raises non ha sollevato l'errore. O il codice è sbagliato, o lo è l'aspettativa del test.
Un test passa da solo e fallisce insieme agli altri I test condividono qualcosa — spesso una lista o un dizionario a livello di modulo. È il problema dei dati mutabili condivisi dei capitoli 21 e 26. Usa una fixture.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Eseguendo pytest -q, qual è l'ultima riga del report?
# checks.py
def is_even(n):
return n % 2 == 0
# test_checks.py
from checks import is_even
def test_even():
assert is_even(4)
def test_odd():
assert is_even(7)- A2 failed in 0.01s
- B1 failed, 1 passed in 0.01s
- C1 failed in 0.01s
- D2 passed in 0.01s
add(2, 2) non è 5. Cosa dice pytest -q?
# test_a.py
def add(a, b):
return a + b
def test_add():
add(2, 2) == 5- AUn `AssertionError`
- B1 failed — come dovrebbe
- Cno tests ran
- D1 passed — il test passa
Il primo test mette un articolo nel carrello. Cosa vede il secondo?
# helpers.py
def build():
return []
# test_helpers.py
import pytest
from helpers import build
@pytest.fixture
def basket():
return build()
def test_first(basket):
basket.append("one")
assert len(basket) == 1
def test_second(basket):
assert len(basket) == 0- Afixture 'basket' not found
- B2 failed
- C2 passed — il secondo riceve una lista vuota nuova
- D1 failed, 1 passed — il secondo vede un articolo
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
Prendi library.py dal capitolo 27 — Book e Shelf — e scrivi test_library.py accanto.
Almeno questi:
- Diversi casi di
total_pages()conparametrize, incluso uno scaffale vuoto pytest.raisesconmatch=per il rifiuto dipages=0longest()che restituisceNonesu uno scaffale vuotoby_authorche restituisce esattamente i libri di quell'autore, e una lista vuota per un autore che non c'è- Un test che usa
tmp_path, scrive lo scaffale in JSON e lo rilegge
Poi cinque esperimenti:
- Togli l'
assertda un test e chiama soltanto la funzione. Il test passa? - Rinomina il file di test in
check_library.pyed eseguipytest. Cosa succede? (Se lo chiamilibrary_test.py, viene trovato — perché?) - Togli il controllo
pages < 1diBook. Quali test falliscono, e il report dice perché? - Metti un errore volontario in
total_pages()— per esempiosum(...) + 1. Il report dei fallimenti mostra sia il numero reale sia quello atteso? - Scrivi un test che afferma
0.1 + 0.2 == 0.3. Poi sistemalo conpytest.approx.
Il terzo esperimento insegna più di tutti. Una buona suite di test, quando si rompe, ti dice quale promessa è stata infranta — ed è questo che rende un programma sicuro da modificare sei mesi dopo.
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