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é?"

Testing in Django: come scrivere test che hanno senso

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.ente

factory_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.id

Questo 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 = True

Organizzare 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.py

factories.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.