Unit Testing in Python con pytest
Impara pytest da zero: asserzioni, fixture, parametrizzazione dei test e organizzazione della suite con conftest.py.
pytest è il framework di testing più popolare per Python. Permette di scrivere funzioni di test piccole e leggibili usando semplici istruzioni assert — senza classi boilerplate — mantenendo al tempo stesso la scalabilità per suite di test complesse con fixture condivise, parametrizzazione e plugin.
Questo capitolo copre tutto ciò che serve per testare il codice Python con pytest: installazione, scrittura del primo test, asserzioni ed eccezioni attese, fixture, parametrize, organizzazione dei test con conftest.py, opzioni della riga di comando utili e i problemi più comuni.
Perché pytest?
Python include il modulo unittest, quindi perché usare pytest al suo posto?
| Caratteristica | unittest | pytest |
|---|---|---|
| Sintassi dei test | Classe + metodo | Funzione semplice |
| Asserzioni | self.assertEqual(a, b) | assert a == b |
| Fixture | setUp / tearDown | @pytest.fixture (componibili) |
| Parametrize | Ciclo manuale | @pytest.mark.parametrize |
| Ecosistema di plugin | Minimale | Oltre 1 000 plugin (coverage, mock, ecc.) |
pytest esegue anche i test in stile unittest senza modifiche, quindi puoi adottarlo gradualmente.
Installazione
pytest non fa parte della libreria standard. Installalo con pip all'interno di un ambiente virtuale:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install pytestVerifica l'installazione:
pytest --version
# pytest 8.x.xConsulta Python pip se hai bisogno di un ripasso sulla gestione dei pacchetti.
Il Primo Test
pytest scopre automaticamente i file di test. Per impostazione predefinita cerca:
- File denominati
test_*.pyo*_test.py - Funzioni il cui nome inizia con
test_
Crea math_utils.py con una semplice funzione:
# math_utils.py
def add(a, b):
return a + bOra crea test_math_utils.py nella stessa directory:
# test_math_utils.py
from math_utils import add
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, 1) == 0
def test_add_zeros():
assert add(0, 0) == 0Esegui i test:
pytest test_math_utils.pyOutput:
collected 3 items
test_math_utils.py ... [100%]
3 passed in 0.01sOgni punto rappresenta un test superato. Un test fallito stampa F e mostra il diff completo dell'asserzione.
Asserzioni
pytest riscrive le semplici istruzioni assert durante la raccolta in modo che i fallimenti mostrino un diff dettagliato — senza bisogno di metodi di asserzione speciali.
def test_assertion_diff():
result = [1, 2, 4]
expected = [1, 2, 3]
assert result == expected # pytest shows exactly where lists differUn output di fallimento appare così:
AssertionError: assert [1, 2, 4] == [1, 2, 3]
At index 2: 4 != 3Confronti con Virgola Mobile
Non confrontare mai i float con == — gli errori di arrotondamento lo rendono inaffidabile. Usa pytest.approx:
import pytest
import math
def circle_area(r):
return math.pi * r * r
def test_circle_area():
assert circle_area(5) == pytest.approx(78.53981633974483)pytest.approx accetta una tolleranza opzionale abs o rel:
assert 0.1 + 0.2 == pytest.approx(0.3, abs=1e-9)Testare le Eccezioni Attese
Usa pytest.raises come context manager per asserire che viene sollevata una specifica eccezione:
import pytest
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)L'argomento match è un'espressione regolare verificata rispetto al messaggio dell'eccezione. Se l'eccezione non viene sollevata, pytest fa fallire il test — assicurandosi di intercettare le regressioni dove la gestione degli errori viene rimossa accidentalmente.
Consulta Python Try...Except per un approfondimento sulla gestione delle eccezioni, e Raising Exceptions per come sollevarle intenzionalmente.
Parametrize: Eseguire un Test con Molti Input
@pytest.mark.parametrize consente di eseguire la stessa logica di test su più set di dati senza scrivere un ciclo:
import pytest
from math_utils import add
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(10, -5, 5),
])
def test_add(a, b, expected):
assert add(a, b) == expectedpytest genera un caso di test separato per ogni tupla e li riporta individualmente:
test_math_utils.py::test_add[2-3-5] PASSED
test_math_utils.py::test_add[-1-1-0] PASSED
test_math_utils.py::test_add[0-0-0] PASSED
test_math_utils.py::test_add[10--5-5] PASSEDQuesto approccio è molto più pulito di un ciclo manuale — i fallimenti individuali sono isolati e facili da identificare.
Fixture
Una fixture è una funzione decorata con @pytest.fixture che fornisce setup condiviso (e teardown opzionale) per i test. Invece di ripetere il codice di setup in ogni test, dichiari una fixture una volta e la inietti per nome come parametro del test.
Fixture di Base
import pytest
class UserStore:
def __init__(self):
self.users = []
def add_user(self, name):
self.users.append(name)
def count(self):
return len(self.users)
@pytest.fixture
def store():
return UserStore()
def test_empty_store(store):
assert store.count() == 0
def test_add_user(store):
store.add_user("Alice")
assert store.count() == 1pytest vede che test_add_user ha un parametro chiamato store, cerca una fixture con quel nome, la chiama e passa il risultato. Ogni test riceve un'istanza di fixture nuova — le modifiche in un test non si ripercuotono mai su un altro.
Fixture con Teardown (yield)
Usa yield all'interno di una fixture per dividerla in setup (prima di yield) e teardown (dopo yield). Questo assicura che la pulizia venga sempre eseguita, anche se il test fallisce:
import pytest
import tempfile
import os
@pytest.fixture
def temp_file():
fd, path = tempfile.mkstemp(suffix=".txt")
os.close(fd)
yield path # test receives the path here
if os.path.exists(path):
os.unlink(path) # always runs after the test
def test_write_to_temp_file(temp_file):
with open(temp_file, "w") as f:
f.write("hello")
with open(temp_file) as f:
assert f.read() == "hello"Scope delle Fixture
Per impostazione predefinita, le fixture vengono create e distrutte una volta per ogni funzione di test. Puoi ampliare lo scope per ridurre i setup costosi:
| Scope | Creato una volta per |
|---|---|
"function" (predefinito) | Ogni funzione di test |
"class" | Ogni classe di test |
"module" | Ogni file di test |
"session" | Intera esecuzione dei test |
@pytest.fixture(scope="session")
def database_connection():
conn = create_db_connection()
yield conn
conn.close()Usa lo scope "session" per risorse costose come connessioni al database o processi server. Usa lo scope "function" (il predefinito) per qualsiasi cosa che modifica lo stato.
Fixture Integrate
pytest include diverse fixture integrate utilizzabili senza importare nulla:
tmp_path— unpathlib.Pathche punta a una directory temporanea unica per il test.monkeypatch— sostituisce attributi, variabili d'ambiente o voci di dizionario per la durata di un test, poi ripristina automaticamente.capsys— cattura l'output distdout/stderrin modo da poter fare asserzioni sul testo stampato.
def greet(name):
print(f"Hello, {name}!")
def test_greet_output(capsys):
greet("World")
captured = capsys.readouterr()
assert captured.out == "Hello, World!\n"Usare monkeypatch
monkeypatch è il modo idiomatico per sostituire le dipendenze esterne nei test senza una libreria mock di terze parti:
import time
def get_timestamp():
return time.time()
def test_get_timestamp(monkeypatch):
monkeypatch.setattr(time, "time", lambda: 1_000_000.0)
assert get_timestamp() == 1_000_000.0Dopo il test, time.time viene ripristinato alla sua implementazione originale. Consulta Python Decorators se vuoi capire come funziona @pytest.fixture internamente.
Organizzare i Test con conftest.py
Quando una fixture è necessaria per test in più file, inseriscila in conftest.py. pytest scopre automaticamente i file conftest.py e rende le loro fixture disponibili a tutti i test nella stessa directory e nelle sottodirectory — senza bisogno di importare nulla.
project/
├── conftest.py # shared fixtures live here
├── test_users.py
├── test_orders.py
└── utils/
├── conftest.py # fixtures scoped to this subdirectory
└── test_helpers.py# conftest.py
import pytest
@pytest.fixture
def admin_user():
return {"name": "Admin", "role": "admin", "active": True}# test_users.py — no import needed; pytest injects admin_user automatically
def test_admin_is_active(admin_user):
assert admin_user["active"] is TrueTest Basati su Classi
Puoi raggruppare test correlati in una classe. A differenza di unittest.TestCase, le classi pytest non richiedono ereditarietà:
class TestCalculator:
def test_add(self):
assert 2 + 2 == 4
def test_multiply(self):
assert 3 * 4 == 12
def test_subtract(self):
assert 10 - 3 == 7Le classi sono utili per raggruppare test che condividono un aspetto logico. Evita le classi quando il raggruppamento non apporta un vero vantaggio — le funzioni piatte sono più semplici.
Mark: Salto e Etichette Personalizzate
Il sistema di mark di pytest consente di annotare i test con metadati per un'esecuzione selettiva.
Saltare un Test
import pytest
import sys
@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
assert False
@pytest.mark.skipif(sys.platform == "win32", reason="Linux only")
def test_linux_feature():
assert TrueMark Personalizzati
Registra mark personalizzati in pytest.ini (o pyproject.toml) per taggare i test per categoria:
# pytest.ini
[pytest]
markers =
slow: marks tests as slow (deselect with -m "not slow")
integration: marks integration tests@pytest.mark.slow
def test_large_dataset():
...Esegui solo i test lenti:
pytest -m slowEsegui tutto tranne i test lenti:
pytest -m "not slow"Opzioni Utili della Riga di Comando
pytest # run all discovered tests
pytest test_math_utils.py # run a specific file
pytest test_math_utils.py::test_add # run one test by name
pytest -v # verbose: show each test name
pytest -x # stop on first failure
pytest --tb=short # shorter traceback (default is long)
pytest -k "add" # run tests whose name contains "add"
pytest --lf # re-run only last-failing tests
pytest -q # quiet: minimal outputCopertura dei Test
Installa il plugin di copertura per misurare quali righe vengono esercitate dai tuoi test:
pip install pytest-cov
pytest --cov=math_utils --cov-report=term-missingL'output aggiunge una colonna di copertura che mostra quali righe non sono state raggiunte:
Name Stmts Miss Cover Missing
---------------------------------------------
math_utils.py 2 0 100%Punta a una copertura elevata sulla logica di business critica, ma non inseguire il 100% — testare getter banali spesso aggiunge rumore senza valore.
Problemi Comuni
1. Fixture non trovata. Se pytest riporta fixture 'foo' not found, verifica che la fixture sia in conftest.py o nello stesso file, e che la funzione sia decorata con @pytest.fixture.
2. Errori di importazione durante la raccolta. Se pytest non riesce a importare il tuo modulo, segnala un errore prima di eseguire qualsiasi test. Esegui python -c "import your_module" per diagnosticare.
3. Argomenti predefiniti mutabili nelle fixture. Come le normali funzioni Python, le fixture dovrebbero evitare argomenti predefiniti mutabili. Usa lo scope "function" (il predefinito) per qualsiasi fixture che costruisce un object mutabile.
4. assert nelle funzioni helper. Se chiami un helper da un test e quell'helper contiene assert, assicurati che il suo nome inizi con assert_ (convenzione pytest) in modo che pytest riscriva l'asserzione per un messaggio di errore migliore.
5. Mescolare unittest.TestCase e fixture pytest. pytest esegue i test unittest.TestCase, ma non puoi iniettare fixture pytest nei metodi di TestCase. Usa le classi in stile pytest o i metodi di setup di unittest — non entrambi contemporaneamente.