Implementare una suite di test per un software è un’attività costosa. E’ dunque naturale voler valutare se i test implementati valgano lo sforzo profuso, ovvero se siano efficaci nel rilevare bug e prevenire regressioni.
Premesse
Di seguito si cercherà di elencare tecniche valide sia per i test automatici che per quelli manuali, scendendo talvolta più nel dettaglio per quelli automatici.
E’ utile testare i test?
Si potrebbe pensare di verificare la correttezza e l’efficacia dei test implementando ulteriori test che esercitano i primi.
Tuttavia, i test, sia automatici che manuali, solitamente non si prestano ad esser facilmente testati a loro volta. Ad esempio, i test automatici solitamente creano direttamente al loro interno il SUT (System Under Test) e le altre dipendenze, anziché dare la possibilità di iniettarle. I test manuali, invece, spesso sono test end-to-end che prevedono complesse operazioni di configurazione del SUT. Ciò comporta che i “test dei test” dovrebbero essere ancora più complessi dei test originali, per cui andrebbero a loro volta testati, creando un circolo vizioso.
E’ buona norma invece cercare di implementare i test nel modo più semplice possibile, in modo che si possa facilmente verificarne, ad esempio tramite revisione o debug, il corretto funzionamento e l’efficacia.
Si noti inoltre che le metodologie di sviluppo guidate dai test (TDD (Test Driven Development) e BDD (Behaviour Driven Development)) integrano già un passaggio di verifica del funzionamento dei test. Infatti, per ogni iterazione, innanzitutto si definisce un test che verifichi il comportamento atteso e si accerta che fallisca, dopodiché si implementa il SUT, e infine si riesegue il test per vedere se ha successo (c’è poi un’ulteriore passaggio di refactoring, ma qui non ci interessa). Combinando questa procedura con l’implementazione di test sufficientemente granulari (ovvero che testino un “aspetto” del SUT circoscritto e il più possibile indipendente), si possono limitare i falsi positivi (o falsi negativi, a seconda dei punti di vista), ovvero i test che hanno successo anche se il SUT contiene errori.
Usare il test coverage come metrica
Test coverage è un termine generico che indica quelle metriche basate sul rapporto tra feature/comportamenti/stati/metodi/linee di codice/ecc. coperti (ovvero esercitati) dai test e quelli esistenti nel SUT.
Tra i tipi di test coverage utilizzabili sia per i test manuali che quelli automatici, si annoverano ad esempio quello basato sulla frazione di flussi di caso d’uso (e.g.: per il caso d’uso “l’utente fa login” posso avere i flussi “login con successo”, “credenziali errate” e “utente bloccato”; se testo solo “login con successo”, ottengo un coverage di 1/3) e quello basato sulla frazione di transizioni di una macchina a stati.
Tra quelli solitamente usati solo per i test automatici, invece, ci sono i famosi code coverage. Delle tante alternative possibili, i più usati sono il “line test coverage” (rapporto tra numero righe di codice esercitate ed esistenti), il “branch test coverage” (rapporto tra i branch (if-else, switch, for, ecc.) esercitati ed esistenti) e il “function test coverage” (autoesplicativo). Ci sono anche altre alternative (e.g.: si possono considerare anche i vari “branch” delle espressioni booleane), ma sono meno supportate dai più comuni strumenti di testing automatico.
Un buon test coverage (dove “buon” è da definire di volta in volta a seconda delle caratteristiche del SUT) è necessario per avere test efficaci. Tuttavia, non è sufficiente.
Il problema del test coverage (specialmente se di una delle tipologie comunemente utilizzate) è che non assicura che i vari flussi/transizioni/branch/ecc. coperti siano stati esercitati con una sufficiente varietà di input. Inoltre, non dice nulla sull’efficacia delle asserzioni. Un test potrebbe, ad esempio, coprire una porzione di codice che inserisce un valore errato in una variabile, ma non controllare il valore di quella variabile.
Mutation testing
Una tecnica migliore, ma più onerosa, per valutare l’efficacia dei test è il mutation testing.
Sostanzialmente, si tratta di generare delle mutazioni del SUT, ciascuna con un errore diverso e casuale (e.g.: un’operatore modificato, un’istruzione aggiunta, rimossa o duplicata, ecc.), che vengono poi sottoposte ai test. Test efficaci massimizzano la percentuale di mutanti che fanno fallire almeno un test.
Questa tecnica ovvia alle limitazioni del test coverage, perché se non si eseguono i test con una sufficiente varietà di input, o non si verificano tutti gli output e i “side effect” del SUT, è probabile che un maggior numero di mutanti “sopravviva”, ovvero non faccia fallire nessun test.
E’ tuttavia evidente come generare un buon numero di mutanti, specie per SUT relativamente complessi, sia molto oneroso. Inoltre, non è solitamente fattibile eseguire test manuali su un gran numero di mutanti. Tali problemi limitano l’applicabilità del mutation testing a piccoli SUT soggetti a test automatici. Ad esempio, piccole porzioni critiche di applicazioni più grandi.