La fase dei Test: paura e spavento
La vita di un software, come ogni testo di progettazione insegna, dovrebbe essere ripartita in varie fasi ed a quella di test dovrebbe essere assegnata importanza uguale o superiore alla fase della codifica.
Tale considerazione comporta che nel bagaglio di conoscenze del programmatore ci siano anche le informazioni relative alle tecniche necessarie per implementare dei test efficaci: invece non è così!
Il programmatore è generalmente preparato, motivato ed attivo nello scrivere codice, magari complesso e mission-critical, ma comincia a "sudare freddo e ad avere brividi" quando si programma la fase di test. Questo accade perchè, durante il suo periodo di formazione, si è privilegiato l'insegnamento delle varie tecniche di codifica, senza accennare alle problematiche e, quindi, alle tecniche implementative della fase di test.
Cos'è un test?
La prima necessità è capire che cosa è un test.
Cerchiamo di costruire la definizione con l'interrogarci su quello che è lo scopo del test: evidenziare il comportamento del codice scritto al presentarsi di situazioni differenti.
Il test è una verifica, una validazione del lavoro fatto. Possiamo, quindi, formulare una prima definizione:
il test è la
verifica del funzionamento di una funzionalità
.
Si osserva che una tale definizione implica una doppia natura del test:
-
Controlla il funzionamento di una feature, nel suo complesso e, in tal caso, è definito funzionale (functional test). Il suo scopo è quello di garantire la soddisfazione del cliente.
Generalmente questo tipo di test viene effettuato da un team separato, che ha lo scopo di individuare i malfunzionamenti e, a tal fine, si avvale di strumenti appositi, quali generatori di traffico, profiler, ecc. Esso tratta l'intera applicazione come una scatola nera, verifica il comportamento al variare dei dati di input, all'aumentare delle utenze, ecc.
-
Controlla il funzionamento di una porzione di codice, in tal caso è detto test unitario (unit test). Il suo scopo consiste nel miglioramento della produttività del programmatore, riducendo il suo carico di lavoro alla distanza, ma aumentandolo nel breve periodo. Questa tipologia di test dovrebbe essere patrimonio del programmatore.
Le due definizioni e la conseguente distinzione stanno alla base di tante incomprensioni tra project manager e programmatore, in quanto per il primo "fare i test" significa, in realtà, "fare i test funzionali", mentre per il secondo vuol dire "fare i test unitari".
Come, dove quando?
Per capire l'utilità della fase di test ed implementare dei test efficaci è necessario affrontare tre questioni:
-
come si scrive un test;
-
quando si scrive un test, cioè in che caso è necessario implementare un test e quando no;
-
dove lo si mette, cioè come si incastra il test nella vita del progetto.
Come si scrive un test?
Capire il significato del termine "test" implica una prima conoscenza di tecniche di implementazione: usare un debugger per visualizzare il valore di una variabile, così come il farla scrivere dal programma sullo standard output, sono, forse, i modi più semplici o addirittura semplicistici. È facile immaginare che fare un test con un programma che stampa tutto sullo standard output è assolutamente inutile: li troppo è equivalente al nulla!
Quello che realmente desideriamo ottenere è, a ben vedere, una via di mezzo tra i due modi sopraddetti: vogliamo controllare il valore della variabile in maniera programmatica, cioè senza utilizzare il debugger ed anche vogliamo essere avvisati nel caso (solo in questo caso) in cui qualcosa non abbia funzionato, in modo da non essere sommersi da messaggi a video inutili ed inutilizzabili.
Possiamo, a questo punto, identificare ed elencare le caratteristiche che un programma di test deve avere:
-
eliminazione (o riduzione al minimo indispensabile) della necessità di interazione con il programmatore;
-
capacità di valutare la correttezza o meno dell'elemento che si sta controllando;
-
produzione, come output, solamente dello stretto necessario, in modo da evidenziare eventuali fallimenti.
In sostanza un test deve essere un programma che, in modo assolutamente automatico, possa invocare la porzione di codice da esaminare (punto 1), possa confrontare, sempre automaticamente, il risultato atteso con quello realmente ottenuto (punto 2) e produca un messaggio in output solamente nel caso di un fallimento (punto 3).
In generale, un test unitario è altamente localizzato, dedicato ad una funzione e deve verificarne in dettaglio il funzionamento, assumendo che tutto il resto funzioni correttamente.
È possibile anche stabilire le regole da seguire nella implementazione:
-
verificare il comportamento "regolare", ad esempio: il test, per una funzione che concatena due stringhe in input, si implementa invocando la funzione e passandole due stringhe valide, e verificando che il valore di ritorno sia uguale alla concatenazione delle due stringhe;
-
verificare il comportamento agli estremi, ad esempio: considerando la situazione precedente, si testa la funzione quando una o entrambe le stringhe sono nulle;
-
scrivere, al presentarsi di un bug, uno o più test che riproducano il problema. Dopo aver corretto il codice sorgente, ci si garantisce, almeno in parte, dal ripresentarsi del bug.
Attenzione però: non è necessario scrivere un test per ogni linea di codice, ma, invece, bisogna lavorare su ogni punto che può produrre criticità (tuttavia questa non è l'unica scuola di pensiero: esistono programmatori esperti che ritengono il contrario!). Se una funzione ha solamente compito di accessor (legge e scrive una variabile) è assolutamente inutile testarla! Al contrario, se la funzione effettua anche un controllo sulla dimensione della variabile passata, il test va implementato, in quanto un errore, per quanto banale, sull'operazione di controllo può avere effetti disastrosi sul resto del programma.
In generale, va ricordato che un test parziale è meglio di nessun test: qualsiasi programmatore potrebbe raccontare la storia di un qualche bug, che ha necessitato giorni di lavoro e di debugging prima di essere scovato, proprio perché è stato necessario lavorare su sezioni di codice che, in realtà, non avevano problemi. Se fosse stato previdente e se avesse implementato anche dei test, ne avrebbe in pochi istanti verificato il corretto funzionamento, isolando prima e, con meno lavoro, le parti bacate, riducendo il tempo di debugging.
Certo, un test non elimina del tutto la necessità di debug del codice, ma sicuramente la riduce! Migliori sono i test, maggiore è la probabilità che il codice sia corretto.
Quando si scrive un test
È la seconda allarmante problematica, fortemente legata alla prima, forse la principale causa di incomprensioni tra il project manager e il programmatore: i tempi di consegna di un progetto sono sempre strettissimi e le pressioni fatte "dall'alto" sono spesso il principale motivo per il quale si decide di ridurre o, peggio, tralasciare l'implementazione dei test.
Un test dovrebbe essere scritto in concomitanza con l'implementazione di una feature, in modo da svolgere due compiti:
-
verificare il comportamento della porzione di codice al variare delle condizioni iniziali;
-
permettere un'agevole opera di "rifattorizzazione" (refactoring) del codice, per migliorarne il disegno e/o il funzionamento.
Mentre il primo punto prima è ovvio, il secondo introduce una problematica che si presenta nel medio/lungo periodo: un software è un oggetto "vivo", nel senso che si evolve, che richiede nuove capacità e nuove feature, quindi il codice già scritto deve essere modificato, corretto ed integrato (rifattorizzato). Le vecchie feature non devono tuttavia essere rimosse, perciò il test permette di verificare che le modifiche implementate non abbiano influenzato (in peggio) il funzionamento già acquisito. Al contrario, se si tralascia l'implementazione del test, alla lunga, nemmeno il programmatore avrà più il coraggio di intervenire sul vecchio codice, per paura di introdurre malfunzionamenti vari con la ovvia conseguenza di favorire la morte del software.
Il momento più adatto per scrivere un test è quello immediatamente prima all'implementazione della feature! Sembra un controsenso ma non è così: quando si implementa una nuova funzionalità la prima domanda che ci si pone è quella di capire che cosa serve per implementarla; e cosa c'è meglio di un test che verifica se la risposta che ci siamo dati è corretta?
Tramite il test, inoltre, è decisamente più facile concentrarsi sulle interfacce, piuttosto che sull'implementazione (questo è il paradigma dell'Object Oriented!) migliorandone il disegno ed ottenendo un prodotto di qualità maggiore ed un primo test!
Ovviamente il lavoro di squadra prevede anche l'implementazione di test successivi, infatti, quando arriva il report di un bug, è buona pratica implementare un test che riproduce il problema. L'abbiamo già detto: in questo modo, dopo aver fissato il problema, il test riduce la probabilità di ricomparsa del bug.
Dove si scrive un test
I test unitari sono localizzati e devono verificare una specifica porzione di codice, che, per il paradigma dell'Object Oriented, può essere parzialmente (o completamente) nascosta. L'implementazione del paradigma è dipendente dal linguaggio; ad esempio: Java utilizza una visibilità di package e, di conseguenza, per testare un metodo con questo requisito sia la classe di test che la quella da testare devono stare nello stesso package, mentre il C++ non ha questa caratteristica/necessità.
Una buona e consolidata prassi, però, è quella di separare il codice sorgente del test da quello dell'applicazione, creando due alberature del file system parallele.
È immediato osservare che questa strutturazione induce maggiore chiarezza e ordine nel del progetto, che magari conta già migliaia di file: il codice sorgente del test e quello dell'applicazione sono fisicamente divisi, così che l'identificazione di un test collegato ad una data parte dell'applicazione è facile, in quanto basta spostarsi nella directory gemella del ramo parallelo.
Un ulteriore beneficio di tale ordinamento si osserva nella fase di rilascio: la compilazione eseguita, ad esempio, tramite make o ant, può essere strutturata in modo tale da processare sia il ramo dei sorgenti di test sia quello dei sorgenti dell'applicazione e porre i compilati in una stessa directory di file system, per una release "beta", oppure può essere tralasciato il contenuto della directory dei sorgenti dei test, per una release di "produzione".
Ovviamente questa non è che una soluzione; tanti preferiscono mischiare i codici, assegnando, ad esempio, una discriminante nel nome, in modo da filtrare i test solo successivamente. In questo caso i test possono contenere nel nome il suffisso "test" assegnando ai sorgenti nomi tipo:
ProcessoA.cxx
e
ProcessoATest.cxx
.
Il "dove" è soggettivo e dipendente dalle idee del programmatore. È assolutamente necessario riflettere bene prima di prendere una decisione in un senso oppure nell'altro, in quanto il numero dei test tende ad aumentare nel tempo, e una scelta sbagliata può portare confusione e raddoppiare i problemi invece di risolverli.
Conclusione
La problematica dell'implementazione del test è, quindi, piuttosto complessa, ma anche interessante ed intrigante: per il programmatore lo scrivere un test deve essere una sfida a dimostrazione che il codice che ha elaborato è robusto e a prova di bug.
È compito del project manager creare le motivazioni che rendono questa sfida sempre più appassionante, perchè, alla lunga, il tempo, che pare "perso" nell'implementazione del test, si ripaga con gli interessi, sia per il fatto che si presentano meno bug e malfunzionamenti generali (con una maggiore soddisfazione da parte del cliente) sia perché il team diventa più produttivo, ed i tempo di debugging si riduce drasticamente.