La lezione 19 ha coperto le basi di pytest: scrivi una funzione, fai assert su qualcosa, chiedi una fixture. Questo ti porta gran parte della strada. Questa lezione copre i tre pattern che gestiscono il resto: parametrize quando hai molti casi simili, mock quando hai una dipendenza esterna che non vuoi chiamare, e conftest.py quando devi condividere setup tra file.
Questi tre fanno la differenza tra “lo testerei se potessi” e “fammi buttare giù il test al volo”.
@pytest.mark.parametrize: la tabella dei casi
Una pila di test quasi identici è un odore. L’esempio classico:
def test_slugify_simple():
assert slugify("Hello World") == "hello-world"
def test_slugify_accents():
assert slugify("Café") == "cafe"
def test_slugify_punctuation():
assert slugify("foo, bar!") == "foo-bar"
def test_slugify_empty():
assert slugify("") == ""
Ogni test è un input e un output atteso. parametrize li fa collassare in una tabella:
import pytest
@pytest.mark.parametrize("raw, expected", [
("Hello World", "hello-world"),
("Café", "cafe"),
("foo, bar!", "foo-bar"),
("", ""),
])
def test_slugify(raw, expected):
assert slugify(raw) == expected
pytest riporta ogni riga come un test separato, quindi un fallimento ti dice quale input ha rotto le cose. L’output è simile a:
test_slug.py::test_slugify[Hello World-hello-world] PASSED
test_slug.py::test_slugify[Caf\xe9-cafe] PASSED
test_slug.py::test_slugify[foo, bar!-foo-bar] PASSED
test_slug.py::test_slugify[-] PASSED
Gli ID generati automaticamente nelle parentesi quadre diventano brutti in fretta. Usa l’argomento ids= per nomi leggibili dagli umani:
@pytest.mark.parametrize("raw, expected", [
("Hello World", "hello-world"),
("Café", "cafe"),
("foo, bar!", "foo-bar"),
("", ""),
], ids=["spaces", "accents", "punctuation", "empty"])
def test_slugify(raw, expected):
assert slugify(raw) == expected
Puoi impilare i decoratori parametrize per ottenere il prodotto cartesiano:
@pytest.mark.parametrize("base", [10, 100, 1000])
@pytest.mark.parametrize("exp", [0, 1, 2])
def test_power(base, exp):
assert power(base, exp) == base ** exp
Questo genera nove test: ogni base incrociata con ogni esponente. Usalo con parsimonia, l’esplosione combinatoria è un vero modo di fallire.
Per tabelle più complesse, pytest.param ti permette di attaccare marker a singole righe:
@pytest.mark.parametrize("path", [
"tests/data/small.json",
"tests/data/big.json",
pytest.param("tests/data/huge.json", marks=pytest.mark.slow),
pytest.param("tests/data/broken.json", marks=pytest.mark.xfail),
])
def test_parse(path):
parse(Path(path))
Adesso pytest -m "not slow" salta il file enorme nel tuo loop di sviluppo veloce.
Una nota sull’assistenza AI
Le tabelle parametrize sono una di quelle cose in cui gli assistenti AI sono molto bravi. Passagli la firma della funzione e due o tre coppie di input/output di esempio, e ti genereranno un blocco parametrize completo in pochi secondi, spesso intercettando variazioni di formato a cui non avresti pensato.
L’inghippo: testano quasi sempre solo gli happy path. Se non dici esplicitamente “includi edge case: input vuoti, None, stringhe molto lunghe, Unicode, input malformati, valori al confine”, otterrai un blocco ben formattato che si perde ogni bug interessante. Fai prompt sui casi limite, rivedi il risultato, e aggiungi quel caso che ha rotto la produzione il trimestre scorso, l’assistente quello non lo conosce.
unittest.mock: simulare dipendenze esterne
Il codice reale parla con cose: API HTTP, database, filesystem, l’orologio. I test non dovrebbero. I mock ti permettono di sostituire un oggetto reale con uno finto che registra le chiamate e restituisce quello che gli dici tu.
unittest.mock è nella standard library. I due pezzi che userai sono MagicMock e patch.
from unittest.mock import MagicMock
def test_email_sender():
fake_smtp = MagicMock()
sender = EmailSender(smtp_client=fake_smtp)
sender.send("ada@example.com", "subject", "body")
fake_smtp.sendmail.assert_called_once_with(
"noreply@narcismiclaus.com",
["ada@example.com"],
"Subject: subject\n\nbody",
)
MagicMock accetta qualsiasi chiamata a metodo, restituisce un altro MagicMock, e si ricorda tutto quello che gli è successo. assert_called_once_with controlla la firma della chiamata.
Questo funziona quando il tuo codice accetta la dipendenza come argomento (constructor injection). Quando non lo fa, quando il codice chiama requests.get direttamente dentro la funzione, ricorri a patch:
from unittest.mock import patch
@patch("my_app.weather.requests.get")
def test_get_temperature(mock_get):
mock_get.return_value.json.return_value = {"temp_c": 21.3}
assert get_temperature("Rome") == 21.3
mock_get.assert_called_once_with(
"https://api.weather.example/v1",
params={"city": "Rome"},
)
Oppure come context manager quando ti serve solo per parte del test:
def test_get_temperature():
with patch("my_app.weather.requests.get") as mock_get:
mock_get.return_value.json.return_value = {"temp_c": 21.3}
assert get_temperature("Rome") == 21.3
La regola del path-to-mock
Questo è l’errore più comune in assoluto sul mocking. La regola:
Fai patch del nome dove viene usato, non dove viene definito.
Se my_app/weather.py fa import requests e poi chiama requests.get(...), fai patch di my_app.weather.requests.get. Non di requests.get.
Perché: import requests lega il nome requests dentro il namespace di my_app.weather. Quando fai patch di requests.get alla sorgente, il binding locale in my_app.weather punta ancora alla funzione vera. Quando fai patch di my_app.weather.requests.get, stai facendo patch del binding che il codice vero va a cercare.
Stessa regola per from x import y:
# my_app/weather.py
from datetime import datetime
def now_iso():
return datetime.now().isoformat()
# tests/test_weather.py
@patch("my_app.weather.datetime") # qui, non "datetime.datetime"
def test_now_iso(mock_dt):
mock_dt.now.return_value.isoformat.return_value = "2026-05-01T12:00:00"
assert now_iso() == "2026-05-01T12:00:00"
Metà di tutta la confusione del tipo “ma in produzione funziona!” sui mock viene dallo sbagliare questo.
patch.object
Quando hai già un riferimento all’oggetto, patch.object è più pulito che costruire una stringa:
from my_app import weather
def test_with_object():
with patch.object(weather, "fetch_raw") as mock_fetch:
mock_fetch.return_value = {"temp_c": 21.3}
assert weather.get_temperature("Rome") == 21.3
Mock HTTP specifici per libreria
Per il lavoro HTTP, le librerie dedicate sono più gradevoli del patch grezzo:
responsesperrequests:
import responses
@responses.activate
def test_get_temperature():
responses.get(
"https://api.weather.example/v1",
json={"temp_c": 21.3},
)
assert get_temperature("Rome") == 21.3
pytest-httpxperhttpx:
def test_get_temperature(httpx_mock):
httpx_mock.add_response(
url="https://api.weather.example/v1",
json={"temp_c": 21.3},
)
assert get_temperature("Rome") == 21.3
Questi fanno match su URL/metodo/header e ti permettono di fare assert che siano partite le richieste giuste, senza che tu debba ricordarti l’esatta catena mock.return_value.json.return_value.
conftest.py: fixture condivise, discovery automatica
Una volta che hai più di un file di test, vuoi fixture definite una volta sola e usate ovunque. Questo è conftest.py.
conftest.py è un nome di file magico. pytest lo scopre automaticamente: qualsiasi test in una directory (o sottodirectory) vede automaticamente le fixture definite in qualsiasi conftest.py da quella directory fino alla radice. Niente import.
Un layout tipico:
my_project/
pyproject.toml
src/
my_package/
...
tests/
conftest.py # condiviso da tutto
test_helpers.py
unit/
conftest.py # solo per unit/
test_pure_functions.py
integration/
conftest.py # solo per integration/
test_api.py
test_db.py
Il tests/conftest.py di livello superiore per fixture economiche, sempre attive:
import os
import pytest
from my_package.config import Settings
@pytest.fixture
def settings():
return Settings(
debug=True,
db_url="sqlite:///:memory:",
api_key="test-key",
)
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch):
"""Strip user env vars that could leak into tests."""
for var in ("AWS_PROFILE", "DATABASE_URL", "OPENAI_API_KEY"):
monkeypatch.delenv(var, raising=False)
autouse=True significa che ogni test riceve quella fixture applicata automaticamente, perfetto per l’igiene dell’ambiente.
L’integration/conftest.py per setup costoso che serve solo ai test di integrazione:
import pytest
import docker
@pytest.fixture(scope="session")
def postgres_container():
client = docker.from_env()
container = client.containers.run(
"postgres:16",
environment={"POSTGRES_PASSWORD": "test"},
ports={"5432/tcp": None},
detach=True,
remove=True,
)
wait_for_postgres(container)
yield container
container.stop()
@pytest.fixture
def db(postgres_container):
conn = connect_to(postgres_container)
conn.execute("BEGIN")
yield conn
conn.execute("ROLLBACK")
I test unitari non avviano mai un container. I test di integrazione ne hanno uno avviato una volta per sessione. La separazione è geografica, per directory, e pytest la fa rispettare gratis.
pytest_plugins
Se pubblichi fixture riutilizzabili (una libreria di testing aziendale, diciamo), mettile in un normale modulo Python e di’ al conftest.py di caricarlo:
# tests/conftest.py
pytest_plugins = ["mycorp.testing.fixtures"]
Adesso ogni fixture in mycorp.testing.fixtures è disponibile, esattamente come se vivesse in conftest.py.
Un esempio pratico: testare un client di API a pagamento
Immagina una funzione che chiama un’API a consumo. Sicuramente non vuoi che la tua test suite spenda soldi:
# my_app/translate.py
import httpx
def translate(text: str, target: str) -> str:
response = httpx.post(
"https://api.translate.example/v1",
json={"text": text, "target": target},
headers={"Authorization": f"Bearer {load_api_key()}"},
timeout=10,
)
response.raise_for_status()
return response.json()["translation"]
Il test, usando pytest-httpx e parametrize:
import pytest
@pytest.mark.parametrize("text, target, translation", [
("hello", "it", "ciao"),
("goodbye", "ro", "la revedere"),
("", "it", ""),
], ids=["english-to-italian", "english-to-romanian", "empty-input"])
def test_translate(httpx_mock, text, target, translation):
httpx_mock.add_response(
url="https://api.translate.example/v1",
json={"translation": translation},
)
assert translate(text, target) == translation
def test_translate_raises_on_500(httpx_mock):
httpx_mock.add_response(
url="https://api.translate.example/v1",
status_code=500,
)
with pytest.raises(httpx.HTTPStatusError):
translate("hello", "it")
Tre pattern, un file di test, zero soldi spesi: parametrize per la tabella, un mock HTTP per la rete, e (se httpx_mock venisse da un conftest.py) una fixture che non ha dovuto essere ridichiarata.
La prossima lezione, la 21, lascia il testing a runtime e guarda lo stack di analisi statica: type checker e linter che intercettano una categoria diversa di bug prima ancora che parta un test.
Citazioni: pytest documentation, pytest-httpx, responses, unittest.mock. Retrieval 2026-05-01.