Python, dalle fondamenta Lezione 19 / 60

pytest, da zero alle fixture

Perché pytest ha vinto su unittest, i pattern di test di base, e il sistema di fixture che rende il setup dei test sopportabile.

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.

Cerca