Introduzione al test di mutazione

Test di mutazione (in francese: test di mutazione), ho scoperto di recente questo termine che descrive un processo in grado di rilevare le lacune negli unit test, andando oltre la copertura del codice. Oggi vi presento questo approccio che consiste nell'eseguire questi test manipolando il codice.

Test unitari

Considerando che l'utilità dello unit test è ben consolidata, questo argomento diventa interessante se stai sviluppando un progetto testato, non importa quanto sia importante la copertura del codice.
Gli unit test consentono di evidenziare eventuali regressioni causate da una modifica del codice. In teoria, se i test convalidano il programma, significa che tutto funziona correttamente nell'applicazione. Come prima e spesso unica misura di fiducia, utilizziamo la copertura del codice. Più questa metrica si avvicina al 100%, più ci rassicura che nessuna regressione scivolerà attraverso le crepe. Sfortunatamente, questa affermazione rimane teorica.
I test, sebbene essenziali per la validazione di un'applicazione qualitativa, è difficile da dimostrare o anche solo apprezzare la loro rilevanza.

Copertura del codice e copertura del caso

Copertura del codice al 100% non significa codice convalidato al 100% ma solo il 100% di questo codice eseguito al superamento dei test, niente di più.
La copertura del codice (linea, istruzione, ramo, ecc.) misura solo quale codice è stato eseguito dai test, senza garanzia di rilevamento dei difetti. È in grado di identificare solo il codice non ancora testato.
Il test senza un'asserzione è l'esempio ovvio perché, sebbene eseguito, il codice non viene effettivamente testato. Fortunatamente, questo scenario rimane raro, il più comune è incontrare codice parzialmente testato dalla suite di test. Una suite che testa solo parzialmente il codice che può ancora eseguire tutti i suoi rami.
In alcuni casi, la copertura del codice non è un indicatore di protezione. Qui c'è un semplice esempio:

function isAdult(user) {
    return user.age >= 18;
}

Supponiamo di voler controllare l'età di un utente. Scriveremo il codice seguente per assicurarci che sia major.
Per testare questo codice possiamo provare con 12 e 38 come input. Questa azione sarebbe sufficiente per coprire questo codice al 100%.
Il risultato sarebbe lo stesso se omettessimo di considerare 18 come la maggioranza con questo errore di battitura nel nostro codice:

function isAdult(user) {
    return user.age > 18;
}

…o se abbiamo testato il valore solo 12 anni, o peggio ancora se abbiamo omesso l'affermazione nel nostro test.
Il test di mutazione sarà effettivamente in grado di rilevare se ogni affermazione è testata in modo significativo. È la misura standard per tutti gli altri tipi di copertura.

Altri problemi nel codice

Assumiamo quindi che non vogliamo codice non necessario nella nostra applicazione. In effetti, ogni parte non testata sarà fonte di potenziali bug o addirittura di complessità aggiuntiva se non è essenziale.
Ecco perché il test di mutazione è un ottimo modo per testare la pertinenza di tale codice:
if (someVariable !== null && someVariable.hasValue()) {}
Dobbiamo controllare il valore?nullo”? La condizione è stata aggiunta per abitudine? Potrebbe significare che non siamo sicuri della variabile”qualcheVariabile” e meriterebbe ulteriori analisi. Non possiamo andare più a fondo senza rendercene conto. Anche i test di mutazione ci aiutano in questo.

Test di mutazione: cosa sono?

Per rilevare i difetti nei nostri unit test, esiste una soluzione: test di mutazione.

Questa tecnica dà più fiducia nei nostri test. Il test di mutazione è un concetto abbastanza semplice. Il suo principio è di maltrattare il codice sorgente alterandolo per verificare che i test associati falliscano di conseguenza. I difetti (o le mutazioni) vengono automaticamente inseriti nel nostro codice e quindi vengono eseguiti i test. Se i test falliscono, la mutazione viene uccisa. Se i test passano, la mutazione è sopravvissuta. In questo caso, significa che i test non corrispondono alla complessità del codice e lasciano non testati uno o più dei suoi aspetti. Quindi, la qualità dei nostri test può essere misurata dalla percentuale di mutazioni uccise.
In altre parole, eseguiamo gli unit test su versioni modificate automaticamente del codice. Quando il codice dell'applicazione cambia, dovrebbe produrre risultati diversi e causare il fallimento degli unit test. Se uno unit test non ha esito negativo in questa situazione, potrebbe indicare un errore nella suite di test.
Ecco i passaggi per raggiungere questo obiettivo:

  • Esegui la consueta suite di test per verificare che tutti i test superino il verde.
  • Modifica alcune parti del codice testato prima di eseguire nuovamente la suite di test.
  • Assicurarsi che i test abbiano avuto esito negativo come previsto dopo aver modificato (mutato) il codice testato.

Ripetere i passaggi 2 e 3 finché rimangono possibili mutazioni.
Facciamo un esempio concreto: pensiamo a un mutante come a una classe aggiuntiva con una sola modifica rispetto al codice originale. Questo può essere il cambiamento di un operatore logico in una clausola if come mostrato di seguito:
if( a || b ) {…} => if( a && b ) {…}
Il rilevamento e il rifiuto di tale modifica da parte di test esistenti viene definito uccisione di un mutante. Con una suite di test perfetta, nessun mutante di classe sopravviverebbe. Ma creare tutti i possibili mutanti richiede molte risorse, motivo per cui non è possibile ottenere questo approccio manualmente in scenari reali.
Fortunatamente, sono disponibili strumenti per creare mutanti al volo ed eseguire automaticamente tutti i test per ciascuno. La creazione della trasformazione si basa su un insieme di operatori di mutazione chiamati a rivelare tipici errori di programmazione. L'operatore di mutazione utilizzato per modificare il codice precedente è chiamato operatore di condizione.

in pratica

Questa tecnica si compone quindi di due parti: la generazione dei mutanti, quindi l'eliminazione di questi.
La generazione mutante è la fase di generazione di classi mutanti da classi sorgente. Per iniziare, abbiamo bisogno del codice aziendale su cui vogliamo valutare la pertinenza dei nostri test. Prendiamo quindi un pool di possibili mutazioni, essendo una mutazione una modifica del codice sorgente, come l'azione di sostituire un operatore con un altro.
Ecco alcuni esempi:

  • + diventa –
  • * DIVENTA /
  • >= diventa ==
  • vero diventa falso.
  • eliminare un'istruzione
  • ecc.

Possiamo modificare un'espressione aritmetica e in |e| (ABS), cambia un operatore aritmetico relazionale in un altro (ROR), cambia un operatore aritmetico in un altro (AOR), cambia un operatore booleano in un altro (COR), cambia un'espressione bool/aritmetica aggiungendo − o ¬ ( UOI), modificare un nome di variabile con un altro, modificare un nome di variabile con una costante dello stesso tipo, modificare una costante con un'altra costante dello stesso tipo...
La generazione vera e propria consiste nel ripassare tutte le istruzioni del codice e per ognuna determinare se sono applicabili delle mutazioni. In tal caso, ogni mutazione darà origine a un nuovo mutante.
Per la seguente affermazione:

if (a > 8) { x = y+1 }

Possiamo considerare i seguenti mutanti:

 if (a < 8) { x = y+1 }
 if (a ≥ 8) { x = y+1 }
 if (a > 8) { x = y-1 }
 if (a > 8) { x = y }

Questo processo può diventare rapidamente dispendioso in termini di risorse. Quando il codice da mutare contiene un gran numero di istruzioni e il " pool » di possibili mutazioni è significativo, quindi il numero di mutanti generati aumenta molto rapidamente.
Una volta completato il processo di generazione dei mutanti, i mutanti vengono archiviati fino al passaggio successivo: l'eliminazione!
Per la seconda parte del processo, abbiamo generato molti mutanti che non vogliamo passare attraverso i test; l'obiettivo sarà eliminarne il maggior numero possibile. Per fare questo, la nostra arma sarà il miglioramento degli unit test.

bilancio

Per un dato mutante, ci sono due possibili esiti, o i test sono sempre verdi o almeno uno di essi è diventato rosso.

Di solito vogliamo che i test siano verdi. Ma in questo contesto, stiamo cercando il rosso. In effetti, come abbiamo visto in precedenza, ogni mutante dovrebbe fallire almeno uno degli unit test. Se almeno uno dei test fallisce, ciò dimostra che sono in grado di rilevare le modifiche al codice e quindi di prevenire possibili bug. Se invece tutti i test rimangono verdi, il mutante sopravvive, quindi è rimasto invisibile agli occhi dei nostri test.
Un mutante sopravvissuto è quindi il segno di un test mancante!

Limiti

L'analisi completa del nostro codice può essere noiosa. Come abbiamo visto, il numero di mutanti può aumentare molto rapidamente.
In una prima fase, possiamo ad esempio generare 6000 mutanti. Durante la seconda fase del test, più del 98% di essi verrà eliminato, la percentuale varia in base alla qualità precedente dei tuoi test. Abbiamo ancora da 150 a 200 mutanti rimasti.
Un'analisi manuale di ciascuno di essi richiede molto tempo. Inoltre, i nostri unit test non sono gli unici responsabili della loro sopravvivenza. Potrebbe apparire un "mutante equivalente": un mutante che modifica la sintassi del codice sorgente, senza cambiarne la semantica. Questo tipo di mutante impedisce a un test unitario di rilevarlo.

while(...) {
    index++;
    if (index == 10)
    break;
}

Ad esempio, una mutazione di " == "A" <= produrrà un mutante equivalente. Questo esempio avrà la stessa condizione di uscita del ciclo.
La pre-analisi della copertura del codice, la creazione di mutanti al volo e tutti i test necessari richiedono molto tempo. Ad esempio, un codice con 350 test aumenta il tempo di esecuzione di quattro rispetto a un'esecuzione normale.
Dati questi numeri e per ragioni pratiche, i test di mutazione non possono essere eseguiti con la stessa frequenza dei test unitari. Pertanto, è importante trovare un flusso di lavoro appropriato che offra il miglior compromesso in termini di efficienza. Per i sistemi software di grandi dimensioni, ciò potrebbe significare che il test delle mutazioni sarebbe limitato alle corse notturne.
Prima di implementarli, devi avere un approccio di qualità avanzato. Le prove vanno poste al centro dello sviluppo, per evitare risultati troppo voluminosi da analizzare. Tuttavia, se la copertura del codice ha raggiunto i suoi limiti, questo potrebbe essere un buon approccio con cui sperimentare. Sfortunatamente, gli strumenti attuali non sembrano abbastanza industrializzati.

Test di mutazione e javascript

I test di mutazione sono molto più conosciuti e utilizzati nel mondo di Java o in PHP. Tuttavia, dal 2016 esiste un modo per eseguire test di mutazione in JavaScript grazie a Stryker Mutator. C'è anche il test di mutazione Grunt, la maggior parte del codice sorgente del quale è in fase di migrazione a Stryker.
Ecco il link al Github: http://stryker-mutator.github.io

Conclusione

Questo articolo è stata una rapida introduzione al test di mutazione. Abbiamo affrontato i mutanti del test, abbiamo apprezzato la relazione diretta tra il tasso di mutanti e la qualità di una suite di test esistente e abbiamo osservato la correlazione con la copertura del codice.
Poiché la copertura del codice non è una metrica molto affidabile, I test di mutazione sono un modo semplice e veloce per misurare l'affidabilità degli unit test. Promuoveremo i test di mutazione laddove esiste un problema reale: il codice aziendale.
Tutto sommato, il test di mutazione sembra una bella aggiunta a una serie di strumenti di garanzia della qualità., sulla base di test automatizzati. Questa pratica è abbastanza recente in JavaScript e ancora sconosciuta. Sarà interessante leggere le opinioni e i feedback degli utenti avanzati.
Aurélie Ambal, (@Souvir) JS Craftswoman @JS-Repubblica
[actionbox color="default" title="" description="JS-REPUBLIC La formazione è referenziata da Datadock.
Trova tutti i nostri corsi di formazione sul nostro sito web training.ux-republic.com:

  • Progettazione UX
  • Agile
  • JavaScript

” btn_label=”I nostri corsi di formazione” btn_link=”http://training.ux-republic.com” btn_color=”primario” btn_size=”grande” btn_icon=”stella” btn_external=”1″]