Mutation Test: Testa i tuoi Test con PIT

M

introduzione

Negli ultimi anni i test sono diventati ancor più’  parte integrante della fase di sviluppo del software. Pratiche come Test Driven Development e Continuous Integration sono largamente utilizzate dall’industria. Ma scrivere i test non basta, occorre scrivere test di qualità’:

  • Come possiamo essere sicuri che i nostri test siano efficaci ?
  • Come possiamo accorgerci di aver dimenticato di testare qualche scenario ?
  • Quali certezze abbiamo che i nostri test individuino efficacemente change sul codice che introducono bug ?

IL LIMITE DELLA CODE COVERAGE

Possiamo definire la Code Coverage come la percentuale di linee di codice del progetto che sono state eseguite dai test dopo un’esecuzione.

Molti pensano erroneamente che la code coverage indichi quale codice è stato testato. Essi si sbagliano: la copertura del codice non ti dice quale codice è stato testato, ti dice solo quale codice viene eseguito durante il test. C’è una differenza importante: potresti scrivere un test che colpisce 100 righe di codice, ma ciò non significa che tutte le 100 linee di codice funzionino come ti aspetteresti. Se dovessi scrivere 10 test per quel pezzo di codice, potresti comunque avere la stessa copertura di codice: ricordo ancora durante un esame di università’ un mio bizzarro collega scrisse come da un esercizio un test che copriva il 100% della coverage eseguendo pero’ una sola asserzione

assertEquals(1,1)

come potete immaginare e’ stato bocciato nonostante avesse raggiunto la 100% della code coverage.

Se ci pensate bene l’unico modo per sapere che un test funziona è vederlo fallire di fronte ad un bug. Ed e’ proprio su questa affermazione che si basano i mutation test.

mutation test

Il mutation test è un metodo di test strutturale volto a valutare / migliorare
l’adeguatezza delle suite di test e la stima del numero di errori presenti in
sistemi sotto test. Il mutation test introduce delle piccole mutazioni all’interno del nostro codice sorgente. Esistono diversi tipi di mutazioni:

  • Mutazione per valore: Viene cambiato il valore di una costante o di un parametro utilizzato nella tua unita’ di test ( es: gli estremi di un loop)
  • Mutazione decisionale: vengono alterate le condizioni presenti nella tua unita di test ( if a < b raise error)
  • Mutazione degli statement: vengono cancellati alcuni statement o viene alterato il loro ordine di esecuzione

Dopo aver  introdotto la mutazione, vengono lanciati tutti i test e se ne osserva il risultato. I test su una mutazione devono fallire (ovvero almeno 1 test della nostra suite deve fallire). Se il test non fallisce si dice che il mutante e’ sopravvissuto altrimenti il mutante e’ stato ucciso. Maggiore è la percentuale di mutanti uccisi, più efficaci sono i test Il risultato che vogliamo e’ ovviamente uccidere tutti i mutanti. Se anche tu sei un appassionato della Marvel capirai quindi il motivo della scelta della copertina di questo articolo 🙂

non fissarti sulla code coverage, uccidi i mutanti

Per convincervi della potenza dei mutation test vi mostrerò’ un semplice esempio in java con unit test scritti utilizzando il framework junit. Supponiamo di dover implementare una semplicissima classe che valida una password imputata dall’utente. La specifica e’ molto semplice:

  • non si accetta null o stringa vuota come possibile password
  • la password deve essere lunga almeno 3 caratteri.

Di seguito una semplice implementazione in java dell algoritmo descritto

public class PasswordValidator {

    public PasswordValidator() {
    }

    public boolean isValidPassword(String s) {
        if (s == null) {
            return false;
        }
        if (s.length() == 0) {
            return false;
        }
        if (s.length() >= 3) {
            return true;
        } else {
            return false;
        }
    }
}

Proponiamo anche la classe di test di PasswordValidator

public class PasswordValidatorTest {
    private PasswordValidator passwordValidator;

    @Before
    public void init() {
        this.passwordValidator = new PasswordValidator();
    }

    @Test
    public void shouldNotBeNull() {
        assertFalse(this.passwordValidator.isValidPassword(null));
    }

    @Test
    public void shouldNotBeEmpty() {
        assertFalse(this.passwordValidator.isValidPassword(""));

    }

    @Test
    public void shouldNotBeSmallerThanThree() {
        assertFalse(this.passwordValidator.isValidPassword("ab"));
    }


    @Test
    public void shouldBeGreaterOrEqualThanThree() {
        assertTrue(this.passwordValidator.isValidPassword("abcd"));
    }

}

nota: per chi non conosce Junit: Junit e’ un framework java per la stesura ed esecuzione automatica di test. Lavora ad asserzioni: banalmente confronta il risultato del metodo che si sta testando e il valore atteso, se essi risultano differenti il test fallisce altrimenti passa. Come buona norma si fa un metodo di test per ogni scenario di test; quindi massimo un asserzione per metodo.

Proviamo quindi adesso a lanciare i test di PasswordValidatorTest; il risultato e’ il seguente:

  • verde: tutti i test passati con successo
  • 100% code coverage: ovvero i test hanno sollecitato il 100% delle istruzioni di PasswordValidator

Molti potrebbero concludere ingenuamente che il 100% della code coverage garantisca un alta qualità’ dei test di questo esempio, ma invece non e’ affatto cosi’: i test di questo esempio non sono robusti e ve lo dimostro con un esempio. Supponiamo che durante l’implementazione di PasswordValidator lo sviluppatore commetta questo errore:

  public boolean isValidPassword(String s) {
...
      if (s.length() > 3) {
          return true;
      } else {
          ...
      }
  }

Ovvero che si usi erroneamente l operatore maggiore anziché’ maggiore uguale come guardia. I nostri test avrebbero individuato questo bug ? Provate a modificare quindi il metodo isValidPassword andando ad utilizzare il > e lanciate i test con la code coverage. Il risultato e’ il medesimo

  • verde: tutti i test passati con successo
  • 100% code coverage

Partendo dal presupposto che un test e’ buono se cattura un baco possiamo concludere che il nostro test non e’ robusto  anche se ha  una code coverage del 100%. Ma quindi come possiamo accorgerci che la  nostra suite di test non e’ robusta ? Come possiamo capire come irrobustirla ? La risposta e’ una: lanciamo il mutation test e uccidiamo i mutanti.

mutation test con pit

Introdurre a mano delle mutazioni nel codice non e’ una pratica percorribile; esistono tool in ogni linguaggio che creano in autonomia i mutanti e per ognuno di questi eseguono la suite di test per individuare quanti mutanti sopravvivono. In java lo standard de facto e’ PIT: http://pitest.org/. PIT applica delle mutazioni al bytecode generato dalla compilazione  ed esegue per ogni mutazione la suite di test.  Per installarlo e’ semplice (di seguito mostro come integrarlo nel tuo progetto maven ma sul sito di PIT trovate la documentazione per integrarlo anche in Gradle).

<build>
  <plugins>
    <plugin>
      <groupId>org.pitest</groupId>
      <artifactId>pitest-maven</artifactId>
      <version>1.1.10</version>
    </plugin>
  </plugins>
</build>

Di default PIT introduce mutazioni in tutto il tuo codice. Essendo un operazione costosa l’esecuzione del mutation test, possiamo anche configurare PIT in modo da inserire mutazioni solo in un determinato package o classe.

<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>LATEST</version>
    <configuration>
        <targetClasses>
            <param>com.your.package.root.want.to.mutate*</param>
        </targetClasses>
        <targetTests>
            <param>com.your.package.root*</param>
        </targetTests>
    </configuration>
</plugin>

Andiamo adesso a lanciare PIT all’interno del nostro esempio. Utilizzando maven basta lanciare il goal di PIT all’interno della folder del progetto:

mvn org.pitest:pitest-maven:mutationCoverage

PIT eseguira’ i mutation test e andrà’ a creare dei report html sul risultato di quest’ultimi in target/pit-reports. Questi report saranno utilissimi per avere una misura su quanto sono robusti i nostri test ed eventuali consigli sul come irrobustirli. Ecco il risultato dei nostri mutation test.

Come vedete, la mutation coverage ci sta’ dicendo che sono stati creati 8 mutanti e su di essi sono stati eseguiti i test. 7 mutanti su 8 sono stati uccisi, ovvero almeno un test e’ fallito. Un mutante e’ sopravvissuto e quindi la nostra test suite non e’ robusta. Andiamo ancora di dettaglio nel report in modo di saperne di più’ sul mutante sopravvissuto:

Il dettaglio dell’analisi eseguita da PIT durante il mutation test e’ fantastico:

  • ci mostra i tipi di mutazioni effettuati
  • ci mostra i test eseguiti sui mutanti
  • ci mostra in rosso la riga mutata durante il mutation test e che non ha fatto fallire i test. Inoltre ci evidenzia in rosso anche il tipo di mutazione del mutante sopravvissuto. Nel nostro esempio il mutante sopravvissuto e’ nato da una mutazione della condizione che controlla la lunghezza della password. Introducendo una mutazione decisionale sul controllo della lunghezza della password ( sostituendo il maggiore uguale con il maggiore stretto)  i nostri test non falliscono e quindi il mutante sopravvive.

Da questi report ne se deduce che i test non sono robusti e un eventuale bug su tale condizione potrebbero non essere rilevato dai nostri unit test ( lo avevamo già’ capito ad inizio articolo quando avevamo provato ad inserire il bug su tale condizione). Come possiamo risolvere questa lacuna dei nostri test ? Andiamo ad irrobustire la nostra suite. Come e’ noto negli studi teorici dei test, quando si va a scrivere gli unit test su una proprietà’ che risulta valida all’interno di due estremi occorre prevedere i seguenti scenari di test:

  1. caso di test in cui si ha la proprietà’ fuori dagli estremi consentiti
  2. caso in cui si ha la proprietà’ all’interno degli estremi consentiti
  3. caso in cui si ha la proprietà’ all’interno degli estremi consentiti, in particolare negli estremi minimi e massimi consentiti ( valori limite)

Cosa abbiamo sbagliato ? Non abbiamo previsto uno scenario per il terzo punto descritto sopra. Il requisito

La password deve esser almeno 3 caratteri

necessita anche uno scenario di test che testa il caso limite ovvero una password lunga 3 caratteri. Andiamo quindi ad aggiungere alla nostra suite il seguente test:

@Test
public void shouldBeValidIfThree() {
    assertTrue(this.passwordValidator.isValidPassword("abc"));
}

Coprendo anche il caso limite nei nostri test, adesso rilanciando PIT il report segnala con successo che tutti i mutanti sono stati uccisi dal mutation test e che quindi i nostri test sono sufficientemente robusti

conclusioni

  • Il mutation test risulta essere uno strumento formidabile per avere una valutazione della qualità’ dei nostri test.
  • La code coverage non e’ lo strumento adatto per valutare la qualità’ dei nostri test
  • PIT e’ un fantastico strumento per eseguire mutation test:
    • e’ semplice da configurare
    • e’ molto veloce
    • si integra facilmente con Maven o Gradle
    • i suoi report oltre a darci il numero di mutanti uccisi ci da’ anche alcune informazioni di dettaglio che ci permettono di individuare le righe di codice non testate sufficientemente dalla nostra test suite ( ovvero le righe alterate dalle mutazioni per cui i test non hanno fallito

Purtroppo il mutation test ha un difetto: e’ molto costoso in termini di esecuzione: se avete 200 test e il mutation test individua 500 possibili mutazioni nel vostro codice, occorre come tempo necessario il tempo necessario ad eseguire 200 test moltiplicato per 500, essendo eseguita la suite di test una volta per mutante. Quando il codice sorgente e’ molto grande e il numero di unit test cresce il mutation test puo impiegare anche svariati minuti. Questo ne consegue che non possiamo eseguire di continuo il mutation test come facciamo con gli unit test in TDD. Non esiste una buona norma su quando eseguire i mutation test ma mi sento di darvi i seguenti consigli:

  • eseguite periodicamente il mutation test, dopo aver implementato nuovi test o nuove funzionalità’
  • se eseguirlo in tutto il codice e’ oneroso, eseguitelo solo sui package più critici della vostra applicazione
  • prevedete una macchina dedicata che ad ogni merge esegua il mutation test e che invii i report sui mutanti ai responsabili del progetto al fine di valutare se a fronte di una change si ha perso qualità’ nei test.

A proposito di me

Dario Frongillo

Uno degli admin di Italiancoders e dell iniziativa devtalks.
Dario Frongillo è un software engineer e architect, specializzato in Web API, Middleware e Backend in ambito cloud native. Attualmente lavora presso NTT DATA, realtà di consulenza internazionale.
E' membro e contributor in diverse community italiane per developers; Nel 2017 fonda italiancoders.it, una community di blogger italiani che divulga articoli, video e contenuti per developers.

Gli articoli più letti

Articoli recenti

Commenti recenti