Decine di test backend in una PR: la nostra strategia di test per FastAPI
La descrizione della pull request recitava: «Aggiunta della suite di test: 52 test su autenticazione, livello dati e interfaccia API». È stata integrata tre mesi dopo la messa in produzione del backend FastAPI a cui si riferiva. Non è così che dovrebbero funzionare i test, e non è qualcosa da celebrare. Vale però la pena esaminare la situazione con onestà, perché «test aggiunti in ritardo» è una situazione sostanzialmente diversa da «test mai aggiunti», e la distanza tra le due è proprio quella tra una base di codice che puoi mantenere e una che prima o poi dovrai riscrivere.
Questo articolo descrive l’architettura dei test che abbiamo scelto, i pattern che si sono dimostrati validi nel tempo in un progetto FastAPI reale e il contenuto della PR, al di là del numero di test.
Il contesto del progetto
Il backend è un servizio FastAPI che gestisce operazioni rivolte ai clienti per un operatore e-commerce: gestione degli ordini, query sulle scorte, regole di prezzo e integrazione con l’API di un fornitore di logistica esterno. Il database è PostgreSQL, utilizzato tramite SQLAlchemy con supporto asincrono. L’autenticazione si basa su JWT, con token convalidati da un servizio di autenticazione separato. Durante il normale funzionamento vengono chiamate tre API esterne: quella del fornitore logistico, un servizio di conversione valutaria e un feed di dati sui prodotti.
Tre mesi di attività in produzione hanno significato tre mesi di correzioni, nuove funzionalità e modifiche incrementali, tutti senza test. La base di codice non era impossibile da testare. Era stata progettata pensando alla testabilità: dipendenze iniettate, nessuno stato globale, sessioni del database create per ogni richiesta tramite il sistema di dependency injection di FastAPI. I test, semplicemente, non erano mai stati scritti. Una PR mirata era lo strumento giusto, non perché aggiungere tutta la copertura in una volta sia un modello da raccomandare, ma perché «una PR con i test» viene revisionata, integrata e mantenuta. «Aggiungere i test nel tempo» tende invece a rimanere nel backlog a tempo indeterminato.
La gerarchia delle fixture
Il sistema di fixture di pytest premia una progettazione ragionata. La gerarchia naturale per un progetto FastAPI con un database reale prevede tre ambiti: sessione, modulo e funzione; questa suite si basa principalmente sugli ambiti di sessione e funzione.
Le fixture con ambito di sessione vengono eseguite una volta per ogni esecuzione della suite. È qui che vanno la creazione del database, la creazione dello schema (l’esempio seguente usa Base.metadata.create_all; le migrazioni Alembic trovano posto nella stessa fase) e il popolamento delle tabelle con dati di riferimento che non cambiano tra un test e l’altro. Il motore del database con ambito di sessione è condiviso da tutti i test: il costo della creazione dello schema viene sostenuto una volta sola, anziché una volta per ogni file di test.
Le fixture con ambito di funzione forniscono a ciascun test uno stato isolato. La fixture di rollback delle transazioni è quella cruciale: apre una transazione all’inizio di ogni test e la annulla alla fine. Tutte le scritture effettuate dal test, come creare utenti, inserire ordini o aggiornare prezzi, vengono annullate automaticamente. I test sono isolati senza dover svuotare le tabelle tra le esecuzioni.
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
@pytest_asyncio.fixture(scope="session")
async def engine():
engine = create_async_engine(settings.TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(engine):
"""Each test gets a transaction that rolls back on completion."""
async with engine.begin() as conn:
await conn.begin_nested()
session_factory = sessionmaker(
conn, class_=AsyncSession, expire_on_commit=False
)
async with session_factory() as session:
yield session
await session.rollback()
La chiamata begin_nested() crea un savepoint all’interno della transazione esterna. L’obiettivo è consentire al codice del test di eseguire liberamente commit nella propria sessione, mentre un rollback della transazione esterna durante la pulizia finale annulla tutto. La fixture mostrata non lo ottiene del tutto: engine.begin() esegue il commit quando il suo blocco termina normalmente e session.rollback() annulla solo le operazioni della sessione stessa. Per un isolamento completo, apri la connessione con engine.connect(), avvia la transazione esterna con conn.begin(), associa la sessione con join_transaction_mode="create_savepoint" ed esegui esplicitamente il rollback della transazione esterna dopo il test. Con questa configurazione, le chiamate a session.commit() nel codice applicativo si limitano a rilasciare i savepoint e il rollback esterno continua ad annullarne le modifiche.
Simulare le API esterne
Tre API esterne significano tre potenziali fonti di instabilità per i test che accedono alla rete. La soluzione standard, simulare il client HTTP, è semplice con respx, che intercetta le richieste httpx a livello di trasporto.
import respx
import httpx
import pytest
@pytest.mark.asyncio
async def test_shipping_estimate_returns_carrier_data(test_client, valid_token):
mock_response = {
"carrier": "DHL",
"estimated_days": 3,
"cost_eur": 8.50
}
with respx.mock:
respx.post("https://api.logistics-provider.com/v2/rates").mock(
return_value=httpx.Response(200, json=mock_response)
)
response = await test_client.post(
"/api/v1/shipping/estimate",
json={"destination_zip": "10115", "weight_kg": 2.5},
headers={"Authorization": f"Bearer {valid_token}"}
)
assert response.status_code == 200
assert response.json()["estimated_days"] == 3
Il gestore di contesto respx.mock instrada tutte le richieste httpx in uscita attraverso il livello di simulazione. Qualsiasi richiesta non registrata esplicitamente genera un errore, ed è il comportamento predefinito corretto. I test che raggiungono per errore API di produzione sono una categoria di bug difficile da rilevare e costosa quando provoca effetti collaterali.
Per il servizio di conversione valutaria, chiamato per ogni ordine in una valuta diversa dall’EUR, una fixture condivisa fornisce un router respx preconfigurato con tassi di cambio standard. I test sulla logica valutaria sovrascrivono specifiche coppie di tassi; quelli su comportamenti non correlati che attivano comunque la dipendenza valutaria usano i valori predefiniti della fixture senza doversene occupare. Questa separazione mantiene chiaro lo scopo dei test: i test sulle spedizioni riguardano le spedizioni, non il tasso di cambio configurato in quel momento.
Testare i flussi di autenticazione
Il sistema di dependency injection di FastAPI è uno dei suoi maggiori punti di forza per i test. In questo progetto l’autenticazione è una dipendenza: ogni route protetta dichiara current_user: User = Depends(get_current_user), dove get_current_user convalida un JWT e restituisce un oggetto utente. Nei test, questa dipendenza viene sostituita a livello di applicazione:
from app.main import app
from app.dependencies import get_current_user
from app.models import User
def make_user(role: str = "operator") -> User:
return User(id="test-user-id", email="[email protected]", role=role)
@pytest.fixture
def operator_client(db_session):
app.dependency_overrides[get_current_user] = lambda: make_user("operator")
client = TestClient(app)
yield client
app.dependency_overrides.clear()
@pytest.fixture
def admin_client(db_session):
app.dependency_overrides[get_current_user] = lambda: make_user("admin")
client = TestClient(app)
yield client
app.dependency_overrides.clear()
Fixture distinte per ruoli distinti rendono espliciti i test sui limiti delle autorizzazioni. Un test che verifica una risposta 403 quando un operatore tenta un’azione da amministratore è chiaro quanto la regola aziendale che fa rispettare. Se le fixture si chiamano operator_client e admin_client, lo scopo del test è evidente senza leggerne il corpo.
Un piccolo gruppo di test verifica direttamente il percorso di convalida dei JWT: token scaduti, malformati o con dichiarazioni del destinatario non corrette. Questi test non usano la fixture di sostituzione della dipendenza: passano i token direttamente al client di test e verificano le risposte 401 o 403. La separazione è intenzionale: la maggior parte dei test riguarda il comportamento dopo l’autenticazione e usa quindi la sostituzione. I test di autenticazione riguardano invece la logica di autenticazione stessa. Mescolare i due approcci rende meno chiaro ciò che ciascun test copre.
Testare deliberatamente i percorsi di errore
I test dei casi di successo sono semplici da scrivere e spesso sono gli unici scritti quando il tempo stringe. I bug che arrivano in produzione raramente riguardano questi casi. Per questa base di codice, i comportamenti non testati con le conseguenze maggiori erano nella gestione degli errori: cosa succede quando l’API logistica restituisce 503? Cosa restituisce l’API quando mancano campi obbligatori nella richiesta? Cosa succede quando una modifica concorrente viola un vincolo del database?
Per ogni simulazione di un’API esterna includiamo almeno un caso di errore accanto al caso di successo. La fixture dell’API logistica ha una variante che restituisce 503, per verificare che l’endpoint restituisca una risposta di errore strutturata con un codice di stato HTTP appropriato, anziché lasciare che l’eccezione si propaghi come errore 500. I test sui vincoli del database tentano inserimenti in conflitto e verificano che l’API restituisca 409 anziché un errore di integrità non gestito.
Questa copertura non nasce spontaneamente scrivendo i test a posteriori. Richiede di elencare deliberatamente le possibili modalità di errore durante la progettazione dei test. È un valido argomento a favore della scrittura dei test insieme al codice: è più facile individuare i casi di errore quando l’implementazione è recente e i casi limite sono ancora presenti nella memoria di lavoro.
Il ritorno sull’investimento dei test tardivi
Aggiungere i test con tre mesi di ritardo è meglio che non aggiungerli mai, per ragioni concrete. Durante la sua preparazione, la PR ha individuato tre bug già presenti: un controllo dei valori nulli mancante su un campo facoltativo dell’indirizzo, che causava un errore 500 per gli ordini senza una seconda riga nell’indirizzo di fatturazione; un errore di una posizione nella logica di paginazione, che si manifestava solo con risultati vuoti; e un codice di stato HTTP errato, 200 invece di 201, su un endpoint di creazione delle risorse. Questi bug erano in produzione da mesi senza provocare segnalazioni degli utenti, ed è proprio per questo che non erano stati rilevati. I test li hanno trovati in un pomeriggio.
Il vantaggio più duraturo è la documentazione del comportamento. Una suite che copre i limiti dell’autenticazione, le interazioni con API esterne e le condizioni di errore è una specifica di ciò che fa l’API, che rimane accurata finché i test passano. Il test «l’operatore non può accedere all’endpoint di amministrazione» non intercetta soltanto le regressioni: comunica l’intento a chi leggerà il codice in futuro. Per un servizio che sarà mantenuto da qualcuno diverso dall’autore originale, come prima o poi accade a ogni servizio, quella specifica vale molto più dei bug individuati al momento della scrittura.
L’argomento contro i test tardivi è che il costo si concentra all’inizio: una PR grande, uno sforzo di revisione intenso, tempo che si sarebbe potuto dedicare alle funzionalità. È vero. Ma l’alternativa, una base di codice che rimane senza test perché il momento giusto per aggiungerli non arriva mai, comporta un costo crescente che raramente viene misurato, perché è distribuito su ogni modifica successiva. Una suite aggiunta tardi costa molto una volta sola. L’assenza di una suite costa continuamente.