Test del codice: cosa sono, quando e come crearli

Se un programmatore potesse scegliere un solo campo in cui essere certo di eccellere, probabilmente dovrebbe scegliere la capacità di scrivere test.

In questo articolo vorremmo comunicare cosa si intende per test, la loro importanza, come e quando applicarli e quali caratteristiche dovrebbero avere.

Definizione di test

Intenderemo per test una porzione di codice atta a verificare in modo automatico che il codice da consegnare al committente funzioni come previsto. Questa definizione è in linea con quanto sostengono molte voci dello sviluppo Agile, come ad esempio TDD, ma siamo consapevoli che in altre aree dell’informatica si usano definizioni diverse.

Perché è importante

Inserire la scrittura di test all’interno del flusso produttivo è una delle buone pratiche più essenziali e fruttuose, e risulta più importante (a nostro avviso) anche dei design pattern, nonostante il ruolo fondamentale che hanno nella modellazione ad oggetti e per le soluzioni informatiche più evolute. Le ragioni di questa importanza sono sostanzialmente due: la prima è che solo con i test riesco a vedere se il codice effettivamente funziona. Vedremo meglio questo aspetto tra poco.

La seconda ragione sta in un effetto benefico collaterale: la sola intenzione di testare il nostro codice ci porta a scriverlo in modo migliore, perché un codice che nasce con una struttura tale da poter essere testato significa, quasi sempre, aver applicato il pattern giusto nel posto giusto.

Facciamo un esempio: supponiamo di voler scrivere una classe con un metodo che in modo randomico ma con frequenza equivalente restituisca il valore Hello oppure Ciao. Un esempio di questa classe potrebbe essere:

class Hello {
   public String sayHello() {
      Random rnd = new Random();
      if ( rnd.nextInt( 2 ) % 2 == 0 ) {
         return "Hello";
      } else {
         return "Ciao";
      }
   }
}

Ovviamente la prima domanda da farsi è: questa classe funziona?

L’esempio fatto è chiaramente molto elementare ma ha una sua validità in quanto le scelte random sono usate anche in casi come l’algoritmo di backtrack per l’addestramento delle reti neurali (estremamente più complicato del nostro esempio, ovviamente).

Torniamo alla nostra domanda e convertiamola in modo più operativo: esiste un insieme di test che possano dirmi, in modo semplice e automatico, che il mio codice funziona?

Creare il test

Nel nostro esempio il problema principale è l’elemento della randomizzazione, che rende difficile la scrittura di test. Proviamo a trovare una soluzione.

Quello che segue non fa uso di alcuna tecnologia particolare; l’intento per ora è mostrare bene il concetto e indicare la miglior implementazione possibile.

Modifichiamo la classe nel modo seguente:

class Hello {
   public String sayHello() {
   private Random rnd;

   public Hello( Random rnd ) {
      this.rnd = rnd;
   }
   public Hello() {
      this( new Random());
   }
   public String sayHello() {
      if ( rnd.nextInt( 2 ) % 2 == 0 ) {
         return "Hello";
      } else {
         return "Ciao";
      }
   }
}

Costruiamo inoltre la classe FakeRandom, che useremo per creare i test

class FakeRandom extends Random {
   private int value;

   public FakeRandom( int value ) {
      this.value = value;
   }
   public int nextInt( int n ) {
     return value;
   }
 }

La classe FakeRandom restituisce il valore fake che ho passato nel costruttore.

Adesso sono in grado di scrivere il test:

public void testHello() {
   assertEquals( "Hello", new Hello( new FakeRandom( 2 )).sayHello());
   assertEquals( "Ciao", new Hello( new FakeRandom( 1 )).sayHello());
}

Senza addentrarci troppo nei tecnicismi e con la consapevolezza di aver semplificato allo scopo di chiarire il concetto, possiamo affermare che il codice restituisce Hello per i numeri pari e Ciao per i numeri dispari e che, se questi numeri vengono generati in modo casuale, allora il nostro codice fa quanto atteso.

Un codice migliore

Senza quasi rendercene conto, o almeno senza averlo chiaramente esplicitato, l’aver predisposto la classe Hello a dei test ci ha indotti a produrre codice migliore, portandoci ad utilizzare almeno due pattern Strategy e IOC.

Abbiamo già parlato di buone pratiche, in passato, e questo ne è un fulgido esempio: saper scrivere test è essenziale per produrre codice in modo efficiente, e un codice efficiente è un codice che fa quanto atteso senza contenere bug. Insomma, esattamente quello che dobbiamo consegnare al cliente.

L’uso dei test riduce in modo significativo il tempo di consegna, inteso come il tempo necessario a far avere al cliente la prima versione funzionante di quanto ha chiesto. Lo specifichiamo perché non si confonda il tempo di consegna con la prima data di rilascio, in seguito alla quale comunque i test saranno determinanti.

Quante volte dopo un primo rilascio ne seguono infiniti altri, costellati da nottate di debug per correggere i vari bug sfuggiti in fase di scrittura? E’ una situazione a cui purtroppo molti sviluppatori e committenti sono abituati, una situazione che genera frustrazione e disappunto a entrambi.

Scrivere test significa produrre codice che verifica in modo automatico il codice vero e proprio.

E i compilatori?

Beh, è tutto qui? Ci sono già i compilatori, no?

No. Purtroppo la risposta è negativa. I compilatori rivelano solo una piccola parte degli errori che possono finire nel codice, altrimenti i bug non esisterebbero affatto. Inoltre hanno sempre più importanza dei linguaggi che non sono fortemente tipizzati, come Javascript, e per i quali riescono a intercettare un numero ancora minore di errori.

Quindi non possiamo farci affidamento, non per una questione così importante come il controllo della qualità del nostro codice.

Le caratteristiche di un test

Quali caratteristiche deve avere il nostro codice per essere considerato un test?

Spoiler: non basta taggarlo con @Test.

Per essere tale, un test deve rispettare alcune condizioni che ricorderemo qui di seguito, specificando però che esistono un numero notevole di manuali che ne parlano in modo molto dettagliato e rigoroso. Facciamo comunque una panoramica:

  1. Un test NON deve dipendere da input esterni, ad esempio non sono accettabili test che richiedono un input all’utente o test che leggono l’ora corrente di sistema, proprio perché l’ora cambia nel tempo.
  2. Il codice dei test deve essere strutturalmente semplice: non può contenere né iterazioni né cicli. La struttura di un test deve essere molto chiara, ad esempio:
    • inizializzo oggetti
    • invoco metodi
    • faccio asserzioni su valori di ritorno o stato degli oggetti
  3. Il risultato del test deve essere stabile. che venga eseguito 100 o un milione di volte e in qualunque momento: il test potrà dare esito positivo (test passato) o negativo (test fallito) ma tale risultato deve rimanere stabile e non dipendere da altri fattori, ad esempio dall’ordine in cui viene eseguito rispetto altri test. Per dirlo in altre parole i test devono essere isolati: questo vuol dire che prima di ogni test occorre preparare una condizione adeguatamente neutra nel quale verrà eseguito, e successivamente quella condizione deve essere ripristinata, ad esempio ripulendo risorse precedentemente acquisite. Questa caratteristica è molto importate, come vedremo più avanti.

Fissati questi 3 principi cardine occorre osservare che esistono pattern specifici per la progettazione e la scrittura di test, e le classi Fake ne sono un esempio (per approfondimenti si consiglia, ad esempio, Test-Driven Development: By Example di Kent Beck).

Quando serve un test

Nel libro appena citato, TDD di Kent Beck, viene descritto un metodo (pienamente condivisibile) secondo cui i test si scrivono addirittura prima della del codice vero e proprio. In questo modo oltre all’ovvio beneficio di produrre un codice che non conterrà bug (o comunque in minima quantità), si potranno sfruttare i test come veri e propri strumenti di design e progettazione delle classi oggetto di realizzazione.

Granularità

La quasi totalità delle considerazione riportate nei libri sul TDD è, a nostro avviso, condivisibile a patto che si stia parlando di test di unità.

Un tema rilevante quando si parla di test è la loro granularità. Possiamo dire che:

  • Quando si scrivono test che verificano il funzionamento di una sola classe (o comunque poche classi) si parla di test di unità.
  • Quando si scrivono test che hanno uno scope più ampio, ad esempio test relativi a sottosistemi come DAO che accedono a un database, si passa a test di integrazione (tra componenti), fino ad arrivare a test di sistema.

Per ogni livello di granularità esistono tecnologie specifiche. Empiricamente potremmo dire che maggiore è lo scope di un test e maggiore sarà lo sforzo richiesto per crearlo e il tempo necessario ad eseguirlo.

Tenendo conto che esistono quindi test con scope differenti, è possibile enunciare questi principi generali:

  1. Meglio pochi test che nessun test
  2. Partire sempre da test con uno scope più piccolo (sono più veloci da scrivere ed eseguire rispetto agli altri, anche di oltre 3 ordini di grandezza).

Vorremmo anche menzionare alcune tecnologie del mondo JAVA che riteniamo assolutamente da usare: per la maggior parte degli sviluppatori sono note ma potrebbero essere buoni suggerimenti per chi si sta affacciando all’argomento:

JUnit

Tra le molte librerie di testing esistenti, JUnit è una delle più popolari e diffuse, quantomeno per il mondo Java. Fornisce annotazioni e metodi che permettono di descrivere tramite una serie di asserzioni il corpo del nostro test.

Se dovessimo usare JUnit 4 per testare una classe Rectangle che ha due metodi per calcolare area e perimetro, il codice della classe di test sarebbe:

public class RectangleTest {

    @Test
    public void testRectangle() {
        Rectangle rectangle = new Rectangle();
        rectangle.setLength(10);
        rectangle.setWidth(3);

        assertEquals(26, rectangle.getPerimeter());
        assertEquals(30, rectangle.getArea());
    }

}

JUnit mette a disposizione un gran numero di funzionalità per scrivere i test, come ad esempio la possibilità di creare metodi di inizializzazione e di pulizia che vengono lanciati prima di ogni test in modo che, indipendentemente dall’ordine in cui i test vengono lanciati, la condizioni iniziali siano sempre le stesse.

    // ...

    @Before
    public void setUp() {
        // codice di inizializzazione
    }

    // ...

Mockito

Durante la scrittura di un test può capitare di trovarsi a dover gestire classi di cui non è possibile replicare il comportamento al di fuori dell’ambiente di produzione, ad esempio quando abbiamo una classe che interroga un servizio esterno tramite un client. In tal caso può essere utile poter simulare il comportamento di tali classi in modo predicibile e senza dover appoggiarsi a servizi esterni al codice che si sta testando.

In questo caso, si possono usare dei mock, cioè degli oggetti che permettono di sostituire l’implementazione di una classe con quella desiderata. Mockito è una libreria che implementa questa funzionalità.

    // ...

    @Test
    public void test() {
        EmailServiceClient client = Mockito.mock(EmailServiceClient.class);
        Mockito.when(client.sendEmail(any(EmailRequest.class)))
                 .thenReturn(EmailResponse.OK);
        
        UserService userService = new UserService(client);
        userService.resetPassword("userid");
        
        verify(client, times(1)).sendEmail(any(EmailRequest.class));
    }

    // ...

In questo esempio, un servizio che resetta la password di un utente deve contattare un servizio esterno per inviare una mail con le nuove credenziali. Grazie a Mockito è possibile creare una implementazione mock di tale classe e descriverne un comportamento quando un suo metodo viene chiamato con certi parametri (in questo caso definiti in modo generico col matcher any). Tali oggetti possono essere poi interrogati per fare delle asserzioni sul numero di chiamate e sul valore dei parametri che gli sono stati passati.

TestContainers

L’ultima casistica che presentiamo si verifica quando in un test voglio appoggiarmi ad un servizio esterno che invece ho possibilità di controllare, come ad esempio un database. Se ad esempio volessi testare un DAO che scrive su Redis, potrei decidere di avviare un container Docker dell’immagine di Redis prima del test, eseguire il test e poi distruggerlo. In questo caso diventa però difficile gestire l’esecuzione del test in modo automatico e replicarla senza problemi in ambienti diversi, ad esempio dove una porta può essere occupata da un servizio e quindi andare in conflitto.

TestContainers permette di fare proprio questo e, se volessimo usando il caso sopra descritto, risulterebbe più o meno così:

public class RedisDAOTest {

    private RedisDAO dao;

    @Rule
    public GenericContainer redisContainer = new GenericContainer(
                                DockerImageName.parse("redis:5.0.3-alpine")
                              ).withExposedPorts(6379);

    @Before
    public void setUp() {
        String address = redisContainer.getHost();
        Integer port = redisContainer.getFirstMappedPort();
        dao = new RedisDAO(address, port);
    }

    @Test
    public void testSimplePutAndGet() {
        dao.put("hello", "world");
        assertEquals("world", dao.get("hello"));
    }
}

Oltre a permettere di gestire immagini in modo generico, TestContainers ha un discreto numero di moduli aggiuntivi che offrono funzionalità ancora più specifiche per il servizio in questione.


Ci premureremo di aggiornare questo elenco quando avremo altre segnalazioni e ricordiamo che per ognuna delle opzioni citate esistono varianti altrettanto valide.

Note conclusive

In ultimo, qualche nota a margine.

A volte è difficile rispettare l’ortodossia dei test, nonostante se ne conoscano bene e se ne apprezzino grandemente i benefici. E’ anche utile notare come eventuali errori in questa pratica siano decisamente più probabili per i test con granularità superiore ai test di unità.

Le ragioni, secondo noi, sono essenzialmente tre.

Prima di tutto, scrivere test di integrazione (o con scope più alto) necessita di una conoscenza approfondita di tecnologie specifiche per i test. Ad esempio, il supporto di SpringBoot per i test dei DAO o dei servizi rest è molto valido ma per poter essere usato nel modo migliore occorre studiarlo al pari di una tecnologia usata per lo sviluppo del codice stesso.

Secondo: per alcuni componenti serve una conoscenza del loro ruolo architetturale. In altre parole, per scrivere test di integrazione nel modo ed al momento giusto occorre sapere qual è il ruolo di quel componente all’interno dell’architettura generale (dao, servizi rest) e quindi quali sono le responsabilità che è più ragionevole testare e a quale livello. Non avere una chiara visione dei ruoli architetturali porta spesso a situazioni in cui una stessa funzione viene testata in due punti diversi del codice mentre un’altra non lo è mai; o a funzioni testate con test di integrazione quando ne bastava uno di unità.

Il terzo punto che rende faticoso (certe volte faticosissimo) scrivere test, soprattutto di integrazione, è la scrittura del codice di inizializzazione dei dati per il test. Di questo tema, che è ampio e molto interessante, ne scriveremo presto su un articolo sul tema dei Builder.