Il problema con la coverage
C'è un modo sicuro per avere il 90% di coverage senza testare nulla di utile: scrivere test che chiamano ogni funzione una volta, verificano che non sollevi eccezioni, e passano avanti.
La coverage misura quali righe di codice vengono eseguite durante i test. Non misura se i test verificano comportamenti significativi, se testano i casi limite, se fallirebbero quando il codice è sbagliato. Una suite di test con il 60% di coverage che testa la logica critica dell'applicazione è più utile di una al 95% che testa i getter.
La domanda giusta non è "quanto coverage ho?" ma "se questo test fallisce, so esattamente cosa è rotto e perché?"

La struttura di base: TestCase vs pytest
Django include TestCase, una sottoclasse di unittest.TestCase che aggiunge supporto per il database — ogni test gira in una transazione che viene rollbackata alla fine, garantendo isolamento tra i test.
from django.test import TestCase
from myapp.models import Progetto
class ProgettoTestCase(TestCase):
def setUp(self):
self.progetto = Progetto.objects.create(
titolo="Test progetto",
stato="attivo"
)
def test_progetto_creato(self):
self.assertEqual(self.progetto.stato, "attivo")pytest-django è l'alternativa più usata in produzione — stessa integrazione con il database Django, ma con la sintassi più pulita di pytest e accesso ai fixture:
import pytest
from myapp.models import Progetto
@pytest.mark.django_db
def test_progetto_creato():
progetto = Progetto.objects.create(titolo="Test", stato="attivo")
assert progetto.stato == "attivo"In Skara uso pytest-django per tutto — il decorator @pytest.mark.django_db è più esplicito di TestCase nel dichiarare che un test tocca il database, e i fixture di pytest rendono il setup molto più componibile.
Factory Boy: dati di test senza dolore
Il problema più comune nel testing Django è la creazione dei dati. Progetto.objects.create() funziona per casi semplici, ma quando hai modelli con molte relazioni obbligatorie il setup diventa verboso e fragile — se aggiungi un campo required al modello, devi aggiornare ogni create() in ogni test.
factory_boy risolve questo con factory classes:
import factory
from factory.django import DjangoModelFactory
from myapp.models import Progetto, Ente, Responsabile
class EnteFactory(DjangoModelFactory):
class Meta:
model = Ente
nome = factory.Sequence(lambda n: f"Ente {n}")
codice_fiscale = factory.Faker("ssn", locale="it_IT")
class ResponsabileFactory(DjangoModelFactory):
class Meta:
model = Responsabile
user = factory.SubFactory("myapp.tests.factories.UserFactory")
ente = factory.SubFactory(EnteFactory)
class ProgettoFactory(DjangoModelFactory):
class Meta:
model = Progetto
titolo = factory.Sequence(lambda n: f"Progetto {n}")
ente = factory.SubFactory(EnteFactory)
responsabile = factory.SubFactory(ResponsabileFactory)
stato = "attivo"Nei test:
@pytest.mark.django_db
def test_progetto_con_responsabile():
progetto = ProgettoFactory()
assert progetto.responsabile is not None
assert progetto.ente == progetto.responsabile.entefactory_boy crea automaticamente tutti gli oggetti correlati. Se aggiungi un campo al modello, aggiorni solo la factory — non tutti i test.
Cosa testare: la gerarchia delle priorità
In Skara ho definito tre livelli di priorità per i test, dall'alto al basso.
Logica di business e permessi
Questi sono i test più importanti. In Skara i permessi sono granulari a livello di oggetto — un responsabile può vedere solo i progetti del suo ente, un membro della commissione solo le pratiche assegnate alla sua commissione. Se questa logica è sbagliata, gli utenti vedono dati che non dovrebbero vedere.
@pytest.mark.django_db
def test_responsabile_vede_solo_propri_progetti(client):
ente_a = EnteFactory()
ente_b = EnteFactory()
responsabile_a = ResponsabileFactory(ente=ente_a)
ProgettoFactory(ente=ente_a)
ProgettoFactory(ente=ente_b)
client.force_login(responsabile_a.user)
response = client.get("/api/progetti/")
assert response.status_code == 200
assert len(response.json()) == 1
assert response.json()[0]["ente"] == ente_a.idQuesto test fallisce se la query non filtra correttamente per ente. Nessuna coverage al mondo ti dice che questo test manca — devi averlo scritto.
Transizioni di stato
In Skara ogni pratica segue un workflow — bozza → inviata → in valutazione → approvata/rifiutata. Certe transizioni sono permesse, altre no. Testare che le transizioni illegali vengano rifiutate è critico:
@pytest.mark.django_db
def test_non_si_puo_approvare_pratica_in_bozza():
pratica = PraticaFactory(stato="bozza")
with pytest.raises(ValueError, match="Transizione non permessa"):
pratica.approva()API endpoint
Ogni endpoint API ha test che verificano: risposta corretta per utente autenticato, 401 per utente non autenticato, 403 per utente senza permessi, payload di risposta corretto, gestione degli errori.
@pytest.mark.django_db
def test_crea_progetto_richiede_autenticazione(client):
response = client.post("/api/progetti/", {"titolo": "Test"})
assert response.status_code == 401
@pytest.mark.django_db
def test_crea_progetto_con_dati_invalidi(client):
responsabile = ResponsabileFactory()
client.force_login(responsabile.user)
response = client.post("/api/progetti/", {})
assert response.status_code == 400
assert "titolo" in response.json()Signal e task Celery
I signal e i task Celery sono facili da dimenticare nei test — non vengono chiamati direttamente dalla view, quindi non emergono da test semplici. Ma contengono spesso logica critica.
Per i signal, Django offre @override_settings per disabilitarli selettivamente, e mock.patch per intercettarli:
from unittest.mock import patch
@pytest.mark.django_db
def test_signal_scatena_task_celery():
with patch("myapp.tasks.processa_commissione_finalizzata.delay") as mock_task:
commissione = CommissioneFactory(stato="bozza")
commissione.stato = "finalizzata"
commissione.save()
mock_task.assert_called_once_with(commissione.id)Per i task Celery, CELERY_TASK_ALWAYS_EAGER = True li esegue sincrono nei test senza bisogno di un worker:
@pytest.fixture(autouse=True)
def celery_eager(settings):
settings.CELERY_TASK_ALWAYS_EAGER = True
settings.CELERY_TASK_EAGER_PROPAGATES = TrueOrganizzare i test in un progetto reale
In Skara la struttura dei test segue la struttura delle app Django:
myapp/
tests/
__init__.py
factories.py
test_models.py
test_views.py
test_api.py
test_signals.py
test_tasks.pyfactories.py è condiviso tra tutti i file di test dell'app. test_models.py testa la logica nei modelli — metodi, proprietà, validazioni. test_views.py testa le view Django tradizionali. test_api.py testa gli endpoint REST. test_signals.py e test_tasks.py testano rispettivamente signal e task Celery.
La configurazione pytest.ini:
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.test
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = --reuse-db --no-header -q--reuse-db (da pytest-django) evita di ricreare il database da zero a ogni run — le migration vengono applicate solo se lo schema è cambiato. Su un progetto con molte migration, questo riduce il tempo di avvio dei test da decine di secondi a pochi secondi.
La regola che uso in produzione
Quando scrivo una nuova feature in Skara, scrivo sempre almeno questi tre test:
- Il caso happy path — la feature funziona con dati validi
- Il caso di permessi — un utente senza i permessi giusti riceve 403
- Il caso di input invalido — dati mancanti o sbagliati vengono rifiutati con un errore leggibile
Non è una coverage alta. È una coverage utile — i test che, se falliscono, mi dicono esattamente cosa è rotto prima che arrivi in produzione.