Vademecum al testing automatico/1

Che differenza c’è tra unit test e acceptance test? In quali occasioni è opportuno scrivere uno unit test e quando invece è meglio scrivere un acceptance test?

Queste sono domande tipiche e più che legittime quando un team inizia a muovere i primi passi nel mondo del testing automatico. Personalmente scrivo il mio codice attraverso il TDD ormai da parecchio tempo, ma mi è capitato di lavorare con team che scrivono test automatici solo dopo aver scritto il codice applicativo corrispondente. Non solo: uno sviluppatore o un team che si interessano al TDD tipicamente passano qualche tempo praticando test-after, prima di iniziare a muoversi test-driven. È un’evoluzione naturale quindi ed è un po’ come imparare a camminare prima di iniziare a correre. Spero che sia evidente che la mia netta preferenza va verso un approccio test-driven.

Detto ciò, le informazioni che seguono costituiscono un approccio al testing pragmatico e volutamente non formalizzato: hanno l’obiettivo di aiutarvi nelle vostre attività quotidiane. Di conseguenza, le definizioni e i suggerimenti qui riportati non vogliono essere necessariamente a prova di bomba da un punto di vista formale, ma spero che possano essere efficaci nel vostro contesto professionale.

Questa prima parte è concentrata sugli unit test.

 

Test di accensione di uno dei motori dello Shuttle

Unit test

Una breve definizione per capirsi meglio: per unit test o test unitario si intende una serie di istruzioni e asserzioni circa il codice applicativo, avente lo scopo di validarne la funzionalità. L’oggetto dello unit test è una classe (o se preferite un modulo, lavorando in C o in javascript). Ogni singolo caso di test ha tipicamente la seguente struttura:

  1. inizializzazione
  2. test vero e proprio
  3. reset alle condizioni iniziali

Tutti i casi di test relativi alla stessa classe applicativa vengono organizzati in un’unica classe o suite di test. Da qui in avanti farò riferimento a JUnit e Java, ma le considerazioni che seguono possono essere generalizzate a qualunque linguaggio/ambiente di sviluppo.
Un singolo metodo di test è quindi un metodo di una classe di test, annotato con @Test (se state usando JUnit 4) o il cui nome inizia per “test” (se state usando JUnit 3.8). Ogni classe di test viene eseguita in quanto parte di una suite, tipicamente la suite di tutti gli unit test del sistema, definita grazie a un framework come ClasspathSuite od eseguita tramite il runner di un sistema di build come ant, maven e così via.

 

Network tester

Regola 1: ogni volta che scrivete una nuova classe applicativa, testatene il funzionamento completamente con uno o più unit test

La collezione di tutti gli unit test di una classe deve darci totale confidenza che il funzionamento della classe sia corretto: dobbiamo scrivere i nostri unit test facendo finta che questi costituiscano l’unica componente di testing del nostro sistema, totalmente privo di acceptance test. Tra l’altro, a volte ciò è proprio vero, per cui a maggior ragione i test devono essere significativi e completi.
Se la classe è nuova dovreste puntare a ottenere un coverage non inferiore all’80%, lasciando da parte poco più che getters/setters (vedi oltre) e codice relativo alla gestione delle eccezioni che non potete controllare.

 

Grafo completo a 11 nodi

Regola 2: una suite di unit test verifica in modo completo tutte le variazioni significative del comportamento esposto da una classe

Questo può voler dire applicare diversi input e output ai metodi della classe e in generale variare in diverso modo le condizioni iniziali di ogni test.

Gasp! Ciò significa testare esaustivamente tutti i metodi, di tutte le classi, attraverso tutte le possibili combinazioni dei valori di input? Ovviamente no.

Da un punto di vista pratico, solitamente non è utile fare il test di one-liners come getters e setters: è ben difficile che falliscano. E poi il nostro codice dovrebbe avere ben pochi getters e setters, ma questo sarà l’argomento di un altro articolo, per il momento mi fermo qui. D’altro canto, ogni metodo non triviale necessita di almeno uno unit test. Il numero degli unit test effettivi dipende dalla complessità del metodo.

Se avete a che fare con codice legacy, come avviene nella maggior parte dei casi, vi conviene considerare ogni cambiamento richiesto come l’opportunità di migliorare il design e rendere più facilmente testabile il codice esistente. Ad esempio, nell’ipotesi di modificare una classe totalmente priva di test, quantomeno dovrete testare il codice specifico che andrete a modificare. Quando il codice è particolarmente aggrovigliato, limitarsi anche solo al codice modificato può essere veramente difficile, al punto di richiedere rifattorizzazioni spinte di un gran numero di classi. In ogni caso, è sempre possibile limitare il refactoring all’estrazione di una o più nuove classi, nelle quali introdurre i cambiamenti e da testare separatamente. Questo è un argomento complesso che merita una trattazione ben più approfondita e a sé stante, ma in questa sede mi limiterò ad affermare con forza il seguente messaggio: piuttosto che cambiare una “virgola” all’interno di una massa tentacolare di codice maleodorante impossibile da testare, è molto meglio estrarre in una classe separata il minimo indispensabile a implementare il cambiamento richiesto in condizioni testabili. Dopotutto questo è il primo passo verso un futuro migliore per la classe in esame.

 

Copertura satellitare

Regola 3: il coverage di una classe modificata non dovrebbe mai diminuire

Se state cambiando una classe esistente e il coverage della classe diminuisce allora qualcosa di brutto sta succedendo. State testando il nuovo codice o ve ne siete già dimenticati?
In generale non bisogna mai accettare passivamente una situazione di questo tipo qualora si dovesse verificare: ogni modifica di codice esistente non deve comportare una diminuzione nella misura del coverage del codice stesso.

Ci sono alcune eccezioni a quanto appena detto, ovviamente, e come in ogni cosa bisogna usare un po’ di grano salis. Ad esempio, è piuttosto raro testare il codice che costituisce l’infrastruttura di una GUI, a prescindere dalla tecnologia sulla quale si basa (JSP, HTML, SWT o qualche altra combinazione di toolkit grafico / linguaggio dichiarativo per interfacce utente). Il codice dell’interfaccia utente è profondamente influenzato dall’aspetto visuale della GUI, quindi soggetto a elevata volatilità nella sua infanzia, seguita da pressoché totale staticità per il resto dei suoi giorni. Comunque, possono esserci svariati contesti dove può aver senso testare il codice dello strato di interfaccia, in funzione della versatilità del toolkit e/o del linguaggio sottostante. Inoltre, come vedremo nella prossima parte molti team si rifanno ai test di accettazione per verificare la GUI: pratica controversa ma che ha molti vantaggi.

Considerando più in dettaglio applicazioni web, mentre le GUI costruite nell’era di quello che oggi chiamiamo il Web 1.0 mostravano a) una chiara demarcazione del confine tra client e server e b) vedevano il client totalmente passivo, nei giorni di AJAX e del cosiddetto Web 2.0 la linea di demarcazione non è più così netta e il client (il browser in questo caso) esegue spesso e volentieri corpose porzioni di business logic. In casi come questi, ha perfettamente senso testare tutto il codice che vive sul client e che è responsabile di logiche di business significative, il che significa per lo più fare unit testing di codice javascript, cosa ormai facilmente praticabile anche quando ciò comporta una manipolazione piuttosto spinta del DOM.

Un’altra eccezione alla regola 3 si verifica quando fate ampio uso di programmazione generativa: se state usando un tool che processa un DSL esterno il cui risultato è la generazione di un insieme significativo di codice di più basso livello, è tipicamente cosa buona e giusta:

  • testare compiutamente il tool di generazione;
  • se possibile, testare il DSL stesso in modo tale da essere sicuri di esprimere i concetti corretti;
  • in generale, non testare il codice generato, assumendo che sia possibile generare il DSL, o in alternativa fare unit testing del codice generato per tutti quei casi particolari espressi dal DSL, ma non necessariamente per tutto il codice.

È anche immaginabile lavorare con un DSL che inglobi concetti di testing al suo interno, di modo che oltre al codice applicativo anche il codice di test venga generato dal tool. In tal caso, se il DSL è ben disegnato e il tool lavora bene, la regola 3 dovrebbe continuare a essere rispettata.

 

Calendario azteca

Regola 4: scrivere test è un’attività continua e quotidiana

In una tipica giornata di lavoro può essere che spendiate parecchio tempo in attività che con la programmazione non hanno nulla a che fare: email, meeting, supporto ai clienti, ecc. Ma se fate questo lavoro mi aspetto che dedichiate almeno qualche ora, ogni giorno, a pestare furiosamente i tasti sulla tastiera e sbrodolare codice su codice. A meno che non siate impiegati in attività di Big Refactoring senza fine, mi sembrerebbe molto strano se ogni giorno non scriviate almeno un pugno di unit test. Quanti? 2? 10? 50? Dipende da molti fattori ovviamente, ma quello che vorrei farvi capire è che si tratta di un’attività quotidiana. Se il vostro capo vi obbliga a scrivere codice applicativo per dieci giorni e poi a fare unit testing per un giorno (perché il progetto è stranamente in ritardo o per qualche altra inconfessabile ragione), vi consiglio di ribellarvi o di iniziare a dare una bella rinfrescata a quel curriculum stantio che avete aggiornato l’ultima volta quando ancora si faceva la spesa in lire.

Se non potete prendervela con nessuno, prendetevela con voi stessi. 🙂 Ok, magari oggi avete scritto un sacco di JSP (tutto il giorno?!?). Magari avete lavorato con un DSL esterno (vedi sopra). Magari avete rifattorizzato a go-go fino ad arrivare addirittura a ridefinire l’architettura del microprocessore (e in tal caso avete la mia benedizione). Ma in caso contrario c’è qualcosa che non va. Abituatevi all’idea di scrivere tanto codice di test quanto codice applicativo, tutti i giorni.

 

Bang

Regola 5: gli unit test devono essere veloci, compatti e indipendenti

Un singolo unit test deve eseguirsi in un tempo che è nell’ordine della decina di millisecondi, persino se lavorate su hardware di qualche anno fa. Anche se usate reflection a man bassa, uno unit test che impiega più di 100 millisecondi è sicuramente da sistemare: è probabile ad esempio che le classi testate facciano troppe cose e sia necessario isolare meglio le dipendenze.
Tanto per darvi un’idea, ho lavorato con diversi team, in contesti completamente diversi, e ognuno di essi aveva suite di unit test nell’ordine delle migliaia di test, con un tempo di esecuzione attorno ai 20-30 secondi per ogni migliaio.
Ovviamente gli unit test sono molto più veloci degli acceptance test, come vedremo, ma sono anche più numerosi. Diciamo che c’è un fattore 10 tra il numero degli unit test e quello degli acceptance test, ampiamente compensato dal fatto che questi ultimi possono essere cento o mille volte più lenti degli unit test.

Come al solito ci sono eccezioni alla regola: se ad esempio avete classi che implementano delle collezioni concorrenti vorrete magari testarle simulando svariati scenari di accesso da thread multipli. In casi come questi, è perfettamente legittimo avere test molto più lenti degli altri per fare in modo che il timing sia consistente ogni volta che vengono eseguiti, ma ricordiamoci che situazioni come queste sono da considerarsi dei casi limite. Normalmente valgono i principi riportati sopra.

Parlando di timing, c’è un altro aspetto che è bene ricordare: i test devono essere totalmente indipendenti dal tempo attuale del sistema e devono essere indipendenti dalla velocità attuale del sistema stesso. Ciò significa rispettivamente:

  1. nessuna chiamata diretta a System.currentTimeInMillis() o nessuna istanziazione di Date();
  2. nessuna sleep spannometrica nella speranza che un evento asincrono si sia completato nel frattempo.

Anche questo è comunque un argomento che approfondiremo maggiormente in seguito.

Un metodo di test più lungo di 20 linee è sicuramente da ripensare. Diciamo che con un ambiente di sviluppo come Eclipse, dove molte viste diverse sono aperte contemporaneamente, nello spazio limitato della view del codice deve essere possibile vedere un singolo metodo di test nella sua interezza. Se così non fosse dovete rifattorizzare il test e molto probabilmente anche il codice che va a testare. Inoltre, piuttosto che avere 1 test con 10 asserzioni è molto meglio avere 10 test con 1 asserzione ciascuna. Le due cose ovviamente non sono immediatamente scambiabili, ma vi danno un’idea dell’approccio da tenere.

Come è riportato in qualunque guideline per JUnit, vecchia o nuova che sia, dimenticatevi dei costruttori della classe di test e mettete tutto il codice di inizializzazione in opportuni metodi di inizializzazione (annotati con @Before in JUnit 4).

JUnit gestisce perfettamente le eventuali eccezioni sollevate nel corso dell’esecuzione del test, pertanto non è necessario catturarle (a meno di doverci fare qualcosa con esse). Ciò significa che è una buona idea avere un template per i metodi di test che include automaticamente throws Exception anche quando un’eccezione non viene lanciata. In Eclipse:

 

@${testType:newType(org.junit.Test)}
public void ${testname}() throws Exception {
${staticImport:importStatic('org.junit.Assert.*')}${cursor}
}

 

Ogni unit test deve essere totalmente indipendente dagli altri. Ciò significa che deve lasciare il sistema nello stato in cui lo trova, per cui nella malaugurata ipotesi in cui il vostro codice abbondi di singleton o altre forme di variabili globali sotto mentite spoglie, dovrete ricordarvi di ripulirle in un metodo @After. Se usate risorse esterne come database o filesystem, sinceratevi di farlo in condizioni isolate da altri sviluppatori e in modo tale che il vostro test possa funzionare su qualunque piattaforma utilizzata dal team o dall’organizzazione. Ad esempio, se lavorate in Windows la cosa peggiore che potete fare è fare riferimento a file con percorsi assoluti o su drive specifici. È sempre molto meglio usare percorsi relativi e notazioni multipiattaforma (niente “C:\” e i vostri colleghi che usano Mac OS/X o Linux ricominceranno a volervi bene).

Un’idea intelligente è creare una suite che randomizza l’ordine di esecuzione degli unit test, registrando di volta in volta l’ordine appena determinato: avere test il cui ordine cambia in modo non prevedibile aiuta a scoprire più facilmente e più velocemente quei casi in cui lo stato globale del sistema non viene riportato alle condizioni iniziali al termine dell’esecuzione di un dato test. Inoltre, registrando l’ordine di esecuzione è possibile rieseguire la suite in modo tale da riprodurre e fissare la condizione di errore che si è appena determinata.

 

Abu Simbel

Regola 6: rifattorizzate, rifattorizzate, rifattorizzate

Innanzitutto non pensate che il codice di test sia meno importante del codice applicativo: dovete dedicargli le stesse attenzioni, ma semplicemente applicherete dei canoni diversi. Ad esempio, io tendo a usare literals al posto di costanti static final (a meno che non ci sia un significativo problema di duplicazione). Il punto è che il codice di test deve essere mantenuto con cura tanto quanto il codice applicativo.

Qualora il test si rivelasse di difficile scrittura è buona norma rifattorizzare il codice per far sì che possa accogliere il test più facilmente. Per fare ciò potete usare un framework di mock objects (come ad esempio EasyMock, JMock e Mockito), svariate tecniche relative a Test Double oppure potete applicare tecniche di dependency injection per decomporre e ridurre la complessità. Anche questo è un altro grosso argomento che merita una trattazione a sé.

Se una classe applicativa è ben coesa solitamente un’unica classe di test è più che sufficiente. Lavorando con codice legacy però è possibile che temporaneamente dobbiate scrivere test su test, al punto da arrivare a definire diverse classi di test per un’unica classe applicativa: ogni classe di test affronta un uso o uno scenario diverso. Questa non è solo una strategia accettabile, ma può aiutare a rifattorizzare il codice applicativo in esame: le diverse classi di test indicheranno diverse direzioni lungo le quali scomporre la singola classe monstre in una serie di classi più piccole. L’importante è non fermarsi al test.

 

Nella prossima parte definiremo il concetto di test funzionale e di accettazione e risponderemo finalmente alle domande con cui abbiamo aperto l’articolo.

Comments

  1. ahime, troppo spesso ho “sleep spannometriche” nel codice, che oltretutto tendono a rende i test più lenti, attendo avidamente consigli su come toglierle 🙂

  2. @riffraff: si’, promesso, in qualche modo approfondiro’ la cosa.

Policy per i commenti: Apprezzo moltissimo i vostri commenti, critiche incluse. Per evitare spam e troll, e far rimanere il discorso civile, i commenti sono moderati e prontamente approvati poco dopo il loro invio.


Speak Your Mind

*

Time limit is exhausted. Please reload CAPTCHA.