{ } Programmazione

Test unitari: cosa testare, come scriverli e cosa evitare

Guida pratica ai test unitari: struttura Arrange-Act-Assert, scelta dei casi da verificare, test leggibili con pytest ed errori che li rendono fragili.

Scrivere test unitari è una di quelle pratiche che tutti dicono di apprezzare e che molti rimandano. Il motivo è comprensibile: all'inizio i test sembrano codice in più da scrivere, senza un beneficio immediato. Il beneficio arriva dopo, quando modifichi una funzione e dieci test ti dicono in pochi secondi se hai rotto qualcosa. A quel punto i test smettono di essere un costo e diventano la rete di sicurezza che ti permette di cambiare il codice senza paura.

Cos'è un test unitario

Un test unitario verifica una piccola porzione di codice, di solito una funzione o un metodo, in isolamento dal resto del sistema. Deve essere:

  • veloce: millisecondi, non secondi, così puoi eseguirne centinaia a ogni modifica;
  • deterministico: lo stesso test dà sempre lo stesso risultato;
  • indipendente: non dipende dall'ordine di esecuzione né da altri test;
  • leggibile: chi lo legge capisce subito cosa viene verificato.

Se un test accede al database reale, alla rete o al disco, non è più unitario: è un test di integrazione, utile ma diverso.

La struttura Arrange-Act-Assert

Un buon test segue quasi sempre tre fasi:

  1. Arrange: prepari i dati e le condizioni iniziali;
  2. Act: esegui l'operazione che vuoi verificare;
  3. Assert: controlli che il risultato sia quello atteso.

Ecco un esempio con pytest, per una funzione che calcola il prezzo scontato:

import pytest
from negozio import prezzo_scontato

def test_sconto_percentuale_applicato_correttamente():
    prezzo = 100.0                          # Arrange
    risultato = prezzo_scontato(prezzo, 20) # Act
    assert risultato == 80.0                # Assert

def test_sconto_superiore_al_cento_per_cento_rifiutato():
    with pytest.raises(ValueError):
        prezzo_scontato(100.0, 150)

Nota i nomi: descrivono il comportamento atteso. Quando un test fallisce, il nome da solo deve dirti cosa non funziona.

Cosa testare

Non serve testare ogni riga allo stesso modo. Conviene concentrarsi su alcune categorie:

Categoria Esempio per prezzo_scontato
Caso normale 100 euro con il 20% di sconto
Casi limite Sconto 0%, sconto 100%, prezzo 0
Input non validi Sconto negativo, sconto oltre il 100%
Arrotondamenti 9,99 euro con il 15% di sconto
Regressioni Un bug già corretto che non deve ripresentarsi

I casi limite sono dove si nasconde la maggior parte degli errori: liste vuote, valori nulli, il primo e l'ultimo elemento, date a cavallo di mezzanotte o di fine mese. Una buona abitudine è scrivere un test ogni volta che correggi un bug: il test riproduce il problema, fallisce, poi la correzione lo fa passare. Quel bug non tornerà senza che tu te ne accorga.

Cosa non testare

Alcune cose non meritano test unitari dedicati:

  • codice di librerie esterne, che ha già i suoi test;
  • semplici assegnazioni o metodi che restituiscono un attributo senza logica;
  • dettagli di implementazione interni che potrebbero cambiare senza cambiare il comportamento.

L'ultimo punto è il più importante. Testa il comportamento, non l'implementazione. Se un test verifica che una funzione chiami internamente un certo metodo privato, si romperà al primo refactoring anche se il risultato resta corretto. Test di questo tipo scoraggiano il miglioramento del codice invece di proteggerlo.

Dipendenze e test double

Quando una funzione dipende da qualcosa di lento o imprevedibile, come l'ora corrente, un servizio esterno o un generatore casuale, la si può isolare con un test double: un oggetto che sostituisce la dipendenza reale durante il test. Esistono varie forme, dai semplici stub che restituiscono valori fissi ai mock che registrano come sono stati chiamati.

Un'alternativa spesso più pulita è progettare il codice in modo che la dipendenza venga passata come parametro:

def e_scaduto(data_scadenza, oggi):
    return oggi > data_scadenza

Questa funzione si testa senza alcun trucco, passando date fisse. Il codice facile da testare tende a essere anche codice ben progettato.

Copertura: un indicatore, non un obiettivo

La copertura del codice misura quali righe vengono eseguite dai test. È utile per scoprire parti mai verificate, ma una copertura alta non garantisce test buoni: si può eseguire ogni riga senza controllare nulla di significativo. Usala per trovare buchi, non come numero da raggiungere a ogni costo.

Errori comuni

  1. Test che dipendono l'uno dall'altro: se il secondo funziona solo dopo il primo, prima o poi falliranno in modo inspiegabile.
  2. Troppe asserzioni in un test: quando fallisce, non sai quale aspetto si è rotto.
  3. Dati di test casuali senza controllo: rendono i fallimenti impossibili da riprodurre.
  4. Ignorare i test che falliscono a intermittenza: un test instabile insegna al team a non fidarsi dei test.

In sintesi

  • Un test unitario è veloce, deterministico, indipendente e verifica un comportamento preciso.
  • Organizza ogni test in Arrange, Act e Assert, con un nome che descrive l'attesa.
  • Concentrati su casi normali, casi limite, input non validi e bug già corretti.
  • Testa il comportamento, non i dettagli di implementazione.
  • Usa la copertura per trovare zone scoperte, non come obiettivo in sé.
MM
Marco Marzaduri

Marco Marzaduri si occupa di cybersecurity, informatica forense e sviluppo web. Laureato in informatica e in psicologia, unisce l'analisi tecnica alla comprensione del fattore umano.

Leggi anche