La standard library include unittest, e ci sono ancora progetti seri che lo usano. Ma nel 2026, se fai git clone di una repo Python a caso e guardi in tests/, troverai pytest. Ogni framework grande, ogni cloud SDK, ogni tool interno che ho toccato negli ultimi cinque anni: pytest. La ragione è semplice: pytest ti lascia scrivere un test come una semplice funzione con un semplice assert, e ti dà un sistema di fixture che gestisce il setup senza gerarchie di ereditarietà.
Questa lezione è l’inizio del Modulo 4. Andremo a scrivere test che gli altri esseri umani (e il tuo te futuro) possono effettivamente leggere.
Perché non unittest
unittest è modellato su JUnit di Java. È arrivato in Python nei primi anni 2000, e si vede. Un test in unittest è una classe che eredita da TestCase, con metodi nominati test_*, hook speciali setUp / tearDown e un’API di assertion personalizzata:
import unittest
class TestMath(unittest.TestCase):
def setUp(self):
self.numbers = [1, 2, 3]
def test_sum(self):
self.assertEqual(sum(self.numbers), 6)
def test_max(self):
self.assertGreater(max(self.numbers), 2)
def tearDown(self):
self.numbers = None
La stessa cosa in pytest:
def test_sum():
assert sum([1, 2, 3]) == 6
def test_max():
assert max([1, 2, 3]) > 2
Niente classe, niente ereditarietà, niente self.assertEqual. Solo funzioni e assert. Quando un’assertion fallisce, pytest riscrive lo statement assert al momento dell’import in modo che il messaggio di errore mostri i valori reali senza che tu debba scrivere un messaggio personalizzato:
> assert sum([1, 2, 3]) == 7
E assert 6 == 7
E + where 6 = sum([1, 2, 3])
Questa introspezione, integrata nell’assert rewriter, è la singola più grande vittoria di quality-of-life rispetto a unittest.
Installazione e discovery dei test
Aggiungilo come dipendenza di dev:
uv add --dev pytest
Per convenzione i test vivono in una cartella tests/ di primo livello:
my_project/
pyproject.toml
src/
my_package/
__init__.py
core.py
tests/
test_core.py
test_helpers.py
pytest scopre i test camminando per la working directory in cerca di file nominati test_*.py o *_test.py, e poi raccoglie le funzioni nominate test_* (e i metodi delle classi nominate Test* che non hanno un __init__). Puoi sovrascrivere ogni parte di questo in pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-ra --strict-markers"
-ra mostra alla fine un riepilogo breve di tutti gli esiti diversi da pass. --strict-markers solleva un errore se usi un marker che non hai dichiarato: utile per intercettare i typo.
Eseguire i test
L’intera suite:
pytest
Un singolo file:
pytest tests/test_core.py
Una singola funzione di test (la sintassi :: è il “node id” di pytest):
pytest tests/test_core.py::test_parse_url
Il flag estremamente utile -k, che seleziona i test per match di sottostringa contro il nome del test:
pytest -k "url and not slow"
Fermati al primo failure quando stai facendo debug:
pytest -x
Mostra le print del tuo codice (pytest cattura stdout di default):
pytest -s
La modalità verbose mostra il nome di ogni test mentre gira:
pytest -v
Combinali liberamente. La mia routine quotidiana è pytest -x -v mentre itero, poi pytest per un run completo pulito prima di committare.
Scrivere un’assertion utile
assert è solo assert. Qualsiasi cosa truthy passa; qualsiasi cosa falsy fallisce. Visto che l’assert rewriter scompatta l’espressione, quasi mai hai bisogno di scrivere un messaggio personalizzato:
def test_user_creation():
user = User(name="Ada", age=36)
assert user.name == "Ada"
assert user.age == 36
assert user.is_adult
Per i float, usa pytest.approx:
import pytest
def test_compound_interest():
assert compound(100, 0.05, 10) == pytest.approx(162.89, rel=1e-3)
Per le eccezioni, pytest.raises:
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(10, 0)
def test_invalid_email():
with pytest.raises(ValueError, match="invalid email"):
validate_email("not-an-email")
L’argomento match= esegue re.search contro il messaggio dell’eccezione, il che ti permette di asserire quale ValueError hai ricevuto senza scrivere un’eguaglianza di stringhe fragile.
Per i warning, il pattern parallelo è pytest.warns(DeprecationWarning).
Fixture: il trucco della dependency injection
La maggior parte dei test reali ha bisogno di qualcosa: una connessione a database, una directory temporanea, un client HTTP fasullo, una config parsata. In unittest scrivevi setUp. In pytest scrivi una fixture e ogni test che la vuole la elenca come parametro.
import pytest
@pytest.fixture
def sample_user():
return User(name="Ada", age=36)
def test_user_name(sample_user):
assert sample_user.name == "Ada"
def test_user_is_adult(sample_user):
assert sample_user.is_adult
Quando pytest vede sample_user nella lista dei parametri di un test, cerca la fixture con quel nome, la chiama e passa il valore di ritorno. Tutto qui. Il meccanismo scala da una fixture a cinquanta.
Una fixture può fare setup e teardown usando yield:
@pytest.fixture
def db_connection():
conn = connect("postgresql://localhost/test")
conn.execute("BEGIN")
yield conn
conn.execute("ROLLBACK")
conn.close()
Tutto quello che precede yield viene eseguito prima del test. Tutto quello dopo viene eseguito al termine del test, anche se è fallito. Questa è la sostituzione più pulita di setUp / tearDown che troverai.
Scope delle fixture
Di default una fixture viene ricreata per ogni test che la usa. Per setup costosi questo è uno spreco. Lo scope controlla il riuso:
@pytest.fixture(scope="session")
def docker_postgres():
container = start_postgres()
yield container
container.stop()
@pytest.fixture(scope="function")
def db(docker_postgres):
"""Una transazione pulita per ogni test, su un singolo container condiviso."""
conn = docker_postgres.connect()
conn.execute("BEGIN")
yield conn
conn.execute("ROLLBACK")
I quattro scope sono function (default), class, module e session. La combinazione qui sopra (infrastruttura con scope di sessione e transazioni con scope di funzione) è il pattern standard per i test su Postgres. Il container parte una volta sola per l’intero run di test; ogni singolo test riceve una transazione pulita su cui poi viene fatto rollback.
Comporre fixture
Le fixture possono richiedere altre fixture. pytest costruisce un grafo di dipendenze e le instanzia nell’ordine giusto:
@pytest.fixture
def settings():
return Settings(debug=True, db_url="sqlite:///:memory:")
@pytest.fixture
def app(settings):
return create_app(settings)
@pytest.fixture
def client(app):
return TestClient(app)
def test_root(client):
assert client.get("/").status_code == 200
Il test chiede client. pytest vede che client ha bisogno di app, e app ha bisogno di settings. Le costruisce dal basso verso l’alto e passa client al test. Tu chiedi sempre solo quello di cui hai davvero bisogno.
Fixture built-in che vale la pena conoscere
pytest include un piccolo gruppo di fixture che userai costantemente. Due sono essenziali.
tmp_path ti dà una nuova directory pathlib.Path unica per il test:
def test_writes_file(tmp_path):
target = tmp_path / "out.txt"
write_report(target, data=[1, 2, 3])
assert target.read_text().startswith("Report")
La directory viene ripulita automaticamente. Niente più balletti con tempfile.TemporaryDirectory() e try/finally.
monkeypatch patcha attributi, variabili d’ambiente e sys.path per la durata di un singolo test:
def test_uses_env_var(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key-123")
assert load_config().api_key == "test-key-123"
def test_swap_function(monkeypatch):
monkeypatch.setattr("my_package.core.fetch", lambda url: {"ok": True})
assert run_pipeline() == [{"ok": True}]
Annulla tutto quando il test finisce, così non puoi leakare accidentalmente stato nel test successivo.
Altre buone: capsys (cattura stdout/stderr), caplog (cattura i log record), request (introspezione del test attualmente in esecuzione).
Skip e fallimenti attesi
A volte un test ha senso solo in certe condizioni:
import sys
import pytest
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only")
def test_unix_socket():
...
@pytest.mark.skipif(
sys.version_info < (3, 13),
reason="needs PEP 695 type aliases",
)
def test_new_syntax():
...
xfail dice “questo test ci si aspetta che fallisca adesso, non rendere rossa la CI, ma dimmi quando inizia a passare”:
@pytest.mark.xfail(reason="upstream bug, fix tracked in #1234")
def test_known_bug():
assert call_buggy_thing() == 42
Quando il bug sottostante viene risolto, il test inizia a passare, pytest lo riporta come XPASSED, e tu rimuovi il marker. È una TODO list incorporata.
Dove andare adesso
Adesso ne sai abbastanza per scrivere test sensati per la maggior parte del codice. La lezione 20 copre i tre pattern che trasformano il resto del testing da “impossibile” a “un paragrafo”: parametrize per tabelle di casi, mock per i sistemi esterni, e conftest.py per condividere fixture tra file.
I docs di pytest su docs.pytest.org sono insolitamente buoni: quando ti capita qualcosa che questa lezione non ha coperto, controlla lì per primo.
Citations: pytest documentation, retrieval 2026-05-01.