Che cosa si intende per test?
Si intende una procedura di verifica, in particolare
automatica ma talvolta anche manuale, di un sistema o sua
componente con lo scopo di evidenziarne un suo
malfunzionamento o di fornire una garanzia sul suo corretto
funzionamento. Sono state create diverse tassonomie per
inquadrare i vari tipi di test e sono state proposte diverse
coordinate per la loro classificazione. I test a cui siamo
maggiormente interessati sono automatici e scritti nello
stesso linguaggio di programmazione utilizzato per
codificare il sistema oggetto di testing. Tra questi
particolare attenzione viene riservata ai test di
unità.
Che cosa sono i test di unità?
I test di unità sono dei test che interessano
unità, ovvero "frammenti" di una base di
codice o di un sistema, il più possibile contenuto
nelle dimensioni ed isolato dal contesto di appartenza.
L'idea fondamentale è che per garantire la correttezza di un sistema c'è bisogno anzitutto di garantire la correttezza delle sue unità.
Che cosa si intende per test-case?
Ogni test-case si articola in tre passi:
Quali sono le principali qualità di un test di unità?
Alcune qualità sono irrinunciabili affinché i test siano effettivamente utili: devono essere riproducibili, deterministici nel comportamento, ed automatici.
Altre sono fondamentali per poter parlare propriamente di test di unità e non di altre tipologie di test
Che cosa bisogna testare?
Tutto ciò che potrebbe malfunzionare, con particolare
attenzione alle unità più complesse ma
limitatamente al proprio ambito di intervento. Ad esempio
non ha senso testare che l'operatore new necessario
in java per invocare il costruttore di una classe
restituisca un riferimento non nullo a meno che non si stia
testando il comportamento della JVM!
C'è anche la possibilità di identificare un soglia minima di complessità del codice sotto la quale non è necessario testare. Questa soglia dipende anche dal livello di confidenza del programmatore: ad esempio molti non fanno mai unit-testing sui classici metodi setter&getter, in quanto, anche grazie all'ausilio dei moderni IDE, ritengono i benefici ottenibili dal loro testing inferiori ai relativi costi.
Alcune volte i test di unità sono impiegati come strumento per la specifica precisa, non ambigua, del comportamento di una libreria (e/o di certe versioni di una libreria). In essenza sono viste come uno strumento di documentazione di codice altrui.
Quanto deve essere lungo un test-case?
Il meno possibile: i test ideali sono lunghi una sola righa contenente una sola asserzione i cui
argomenti sono ottenuti creando l'unità da testare e contestualmente sollecitandola.
Più spesso sono lunghi poche righe: all'aumentare del numero di righe di codice vengono via via sempre meno le loro qualità.
Al di là del mero conteggio del numero di righe, è fondamentale che gli unit-test abbiano un'obiettivo nitidamente definito e circoscritto.
Per avere un'idea di come accorciare i test consultare anche B10, B11, B12.
Quante asserzioni vanno incluse in ogni test-case e perché?
Il meno possibile, almeno una, e devono essere comunque nitidamente inerenti lo scopo del test.
Alcune volte si ha la tentazione di fare altre asserzioni perchè la creazione dell'unità oggetto del test e la sua sollecitazione ne rende semplice l'aggiunta in particolari posizioni del test-case. Ad esempio, il test-case test_iterator_next_singletonList() per testare la funzione iterator_next(iterator_t it) di un iteratore it di tipo iterator_t appena creato a partire da una lista di un solo elemento, può agevolmente fare una asserzione del tipo CU_ASSERT_TRUE(iterator_hasNext(it)) subito dopo l'ottenimento dell'iteratore. Tuttavia, questa asserzione allunga il corpo del test-case e ne sfuma il significato, senza nessun reale guadagno se si è già prevista, o comunque si prevede, l'esistenza di un test-case test_iterator_hasNext_nonEmptylist() per assicurare la copertura da parte dei test-case anche della funzione iterator_hasNext(iterator_t it). Quella sarà l'occasione per dedicare l'intero test-case all'asserzione specifica per la funzione iterator_hasNext().
Come scegliere i nomi dei test-case?
Da scoraggiare assolutamente l'utilizzo di nomi anonimi, come ad esempio test1(), test2(), ...
Il nome di un test-case ha un ruolo determinante nel conferirgli alcune delle qualità di cui sopra. Se non ci sono nomi che risultano particolarmente espressivi ed efficaci dettati da contesto di riferimento, è possibile adottare alcune convenzioni che prevedono di formare i nomi dei test-case instanziando alcune informazioni in un pattern comune; ad esempio: test_<tipo>_<funzione>_<scenario>_<variante>, dove:
Possibili esempi di applicazione di questa convenzione sui nomi dei test-case sono (sempre con riferimento alle liste ed agli iteratori): test_iterator_hasNext_singletonList_yes(), test_iterator_hasNext_singletonList_no(), test_iterator_hasNext_emptyList()
Alcune asserzioni si possono rendere in diversi modi,
anche utilizzando distinte funzioni ASSERT_XYZ
offerte dal framework che sto utilizzando
(CUnit). Quale conviene/si deve utilizzare? Esiste
un criterio per scegliere?
Usare sempre la funzione di asserzione più specifica
tra quelle rese disponibili dal framework. Meglio scrivere
CU_ASSERT_FALSE(iterator_hasNext(it)) che scrivere
CU_ASSERT_EQUAL(iterator_hasNext(it), 0). Meglio
CU_ASSERT_EQUAL(max(array),5) piuttosto che
CU_ASSERT_TRUE(max(array)==5).
Migliorerà la leggibilità dei test e la loro
capacità informativa in caso di fallimento.
Che cosa è una 'fixture'?
E' una unità del sistema in uno stato iniziale noto e
pronto a ricevere le sollecitazioni il cui esito sarà
oggetto di verifiche tramite asserzioni. Il termine, oltre
questa accezione in senso lato, viene usato secondo una
accezione più tecnica in cui indica più
specificatamente quei campi e quelle variabili che ospitano
le strutture dati, gli oggetti, e comunque le
rappresentazioni di queste unità fornite dal
linguaggio di programmazione utilizzato.
Che cosa è una funzione 'factory'?
In generale sono funzioni che hanno come scopo quello di creare strutture dati, oggetti, ed in generale risorse articolate, che non possono essere create direttamente.
Nel contesto dello unit-testing, sono funzioni factory più specificatamente quelle che creano fixture, ovvero unità del sistema sotto test in uno stato iniziale noto, pronta per la successiva sollecitazione da parte di un test-case.
Che cosa è una 'asserzione di dominio'?
E' una asserzione specializzata sul dominio di interesse, e
quasi sempre realizzata come serie di asserzioni più
semplici direttamente effettuate con il framework di
riferimento. Fanno parte del codice di test, non del codice
di produzione e possono rendere i test-case molto più
leggibili e corti. Esempi di asserzione di dominio:
ASSERT_BUFFER_CONTAINS(buffer, 2, 3, 4), per
asserire che un dato buffer contiene come suoi elementi
2, 3, 4, ammesso che si stia
testando del codice inerente il dominio dei buffer di
messaggi; nel dominio delle espressioni regolari
ASSERT_REGEXP_MATCHES("ab+c", "abbc"), vuole
verificare che una certa espressione regolare unifica con
una certa stringa.
La creazione di asserzioni di dominio spesso comporta la necessità di verificare se due strutture dati complesse e distinte siano equivalenti, e quindi di realizzare ciò che nel linguaggio java è tipicamente reso con il metodo equals(), ed anche di prevedere la creazione semplice e veloce di strutture dati articolate, anche a partire da rappresentazioni testuali. Queste funzionalità tipicamente sono utili nel codice principale, oltre che nel codice di test, ma ciò non toglie che l'esigenza spesso nasce prima in fase di testing.
Quando scrivere i test?
Il prima possibile, e taluni teorizzano addirittura prima di
scrivere il codice testato (vedi anche
domanda A1) per focalizzarsi da subito
sulla sua facilità di utilizzo.
Come regola generale, i test-case vanno scritti ogni qualvolta non si ha sufficiente confidenza sul corretto funzionamento di una certa porzione del proprio sistema, e questo accade continuamente quando il sistema è in corso di sviluppo.
Un'altra prassi molto diffusa prevede di tenere traccia di ogni bug che si rileva scrivendo un test, al solito il più semplice possibile, che lo renda evidente in un contesto semplificato. Questo ancora prima di correggerlo. E' prassi comune da parte degli sviluppatori, in molti progetti open-source ma non solo, non prendere in carico nessun bug che non sia stato operativamente documentato con un test-case che lo riproduce. Il test-case, una volta funzionante, garantisce inoltre che in futuro quel particolare bug non sarà mai più reintrodotto.
In che ordine bisogna scrivere i test?
In generale ogni buona strategia di testing prevede di testare all'inizio le cose più semplici ed isolate per almeno due buone ragioni: i) per disporre da subito di test di ottima qualità (B1) capaci di individuare sul nascere la presenza di bug nelle parti su cui si regge il resto del sistema; ii) per accumulare confidenza sulla correttezza di quelle parti del sistema più isolate che saranno successivamente sollecitate da porzioni meno isolate (A2).
In questo modo il testing delle porzioni meno isolate godrà di caratteristiche di località migliori in quanto è più probabile che i fallimenti siano dovuti alle porzioni di codice direttamente sollecitate dai loro test, e non da quelli indirettamente sollecitate tramite le prime.
Altri sviluppatori preferiscono seguire meno rigidamente l'ordine imposto dalle dipendenze tra le varie componenti di un sistema, e favoriscono il testing isolato di queste adottando apposite tecniche di "rottura delle dipendenze", ad esempio con l'utilizzo di Mock. Molti considerano queste tecniche benefiche solo se adottate per componenti di una certa complessità ed è controverso il giudizio sulla loro concreta utilità ai test di unità.
Perché tutta questa attenzione all'ordine in cui si scrivono
i test?
Uno degli errori più classici, da parte di chi approccia il mondo del testing
di unità per la prima volta, è di scrivere i test solo dopo aver
scritto il tutto codice principale. Ma in effetti così si percepiscono solo una
minuscola parte dei benefici, pur avendo pagato per intero il prezzo della loro
introduzione.
Quanto bisogna testare?
Ciclo: poca credibilità - testing - eccesso di confidenza - fallimento
Alcuni test-case risultano troppo lunghi in quanto
sollecitano componenti che sono articolati da creare ed
inizializzare. Come rimediare?
Può convenire prevedere apposite funzioni factory da allegare al codice di test ma non al codice principale con il solo scopo di facilitare la creazione di strutture dati complesse al solo scopo di testing. Tali funzioni hanno come uniche responsabilità quella di allocare queste strutture dati e metterle in uno stato iniziale noto, pronte per essere coinvolte nei test-case. Taluni chiamano queste strutture dati nate specificatamente per queste esigenze fixture.
Talvolta l'esigenza di queste funzioni factory possono fornire lo spunto per aggiungere anche al codice principale funzioni che facilitino la creazione di strutture dati inizializzate, ma più spesso vanno tenute semplici, non generalizzate, ed adatte solo all'impiego a fini di testing.
E' necessario ripetere le asserzioni sui componenti
che non sono l'oggetto principale di un test case ma sono
necessari per sollecitare l'aspetto che si vuole
verificare?
No: vedi anche le risposta B7. Per evitare di proseguire l'esecuzione di un test-case quando è già chiaro che il test ha perso di significato, ad esempio perché le fixture non sono nello stato iniziale voluto, si distinguono le "asserzioni" dalle "assunzioni": queste ultime sono asserzioni esplicitamente dedicate a verificare lo stato inizialmente atteso delle fixture prima che possa cominciare la fase di sollecitazione vera e propria. I test che non vanno a buon fine per colpa/merito di una assunzione vanno interpretati più come "test non applicabili" che come test falliti. E' preferibile ignorarli momentaneamente, in quanto non possono contribuire alla ricerca degli errori in maniera subitamente efficace. Al contrario, altri test-case dovrebbero prontamente permettere la riparazione degli errori che hanno invalidato la produzione delle fixture in questione, e rendere questi test nuovamente utili.
Alcuni framework come JUnit prevedono dei meccanismi per distinguere esplicitamente le assunzioni dalle asserzioni, altri, come CUnit, al momento non prevedono meccanismi simili.
Che cosa sono le suite ed a cosa servono?
Sono collezioni di test-case che conviene, per vari motivi, raggruppare in maniera da evidenziare la loro coesione.
Alla base dei criteri di coesione ci può essere la somiglianza semantica, il fatto che operano sulle stesse componenti di un sistema, in certi scenari simili (ad esempio differenziati per il livello di concorrenza esercitato), ma anche l'utilizzo di fixture comuni. Soprattutto con CUnit può prevalere questo ultimo aspetto in quanto il framework prevede la creazione e la distruzione di fixture una volta per ogni suite di test-case, e non una volta per ogni test-case come avviene con JUnit.
Che nomi scegliere per le suite di test?
Da scoraggiare assolutamente l'utilizzo di nomi anonimi, come ad esempio suite1(), suite2(), ...
Non sono popolari convenzioni sui nomi a differenza del caso dei test-case. Ad ogni modo conviene utilizzare il nome per evidenziare il criterio di raggruppamento seguito, o le caratteristiche delle fixture che hanno suggerito la creazione della suite.
I test di unità vanno fattorizzati?
In generale si, ma senza eccedere. Sono da evitare quelle
generalizzazioni del codice ottenibili introducendo livelli
di indirezione che hanno senso solo per il testing ma non
per il codice principale. Del resto, affinché il
testing abbia senso, è necessario che il codice di
test sia sufficientemente semplice da assicurare con ottima
probabilità che gli eventuali fallimenti vadano
ricercati nel codice principale, e non nel codice di test.
D'altra parte, solo con la fattorizzazione l'aggiunta di nuovi test-case può divenire sufficientemente semplice da permettere la creazione di batterie di test esaurienti e capaci di assicurare una buona copertura del codice principale. (Vedi anche B6).
Quindi, in definitiva, il livello di fattorizzazione dei test ottimale da raggiungere è stabilito sulla base di una serie di compromessi dovuti ad esigenze contrapposte e, nell'arco dello sviluppo di un progetto, può variare anche sensibilmente alla stessa stregua di quanto già accade per il codice di produzione.
Che cosa comporta specificatamente il testing unitario di codice concorrente?
Per alcune caratteriche il codice concorrente mal si presta
ad essere oggetto di unit-testing. In particolare il fatto
che se il codice è scorretto esibisce comportamenti non
deterministici dipendenti dalla particolare sequenza di
interleaving (o di esec. ammissibile) scelte dall'esecutore
fisico e normalmente fuori dal controllo dello
sviluppatore. Di conseguenza i corrispondenti test di
unità perdono una delle principali qualità
(B4): quella di essere riproducibili.
Come "forzare" una sequenza di interleaving?
Introducendo opportuni ed espliciti punti di sincronizzazione, ad esempio utilizzando le variabili condizione.
Per semplificare i test-case taluni introducono, nei soli test, pause di lunghezza predeterminate
che rendano talune sequenze di interleaving più probabili di altre, (n.b. non saranno comunque certe).
Ma così non violo l'Assunzione di Progresso Finito?
Assolutamente si! ma non nel codice di produzione, bensí nel codice di test, con conseguenze sperabilmente
meno nefaste: potrebbe accadere (soprattutto cambiando ambiente di esecuzione od in presenza di forte carico della
macchina) che le sequenze di interleaving desiderate dai test che violano l'assunzione di progresso finito non
siano effettivamente esercitate, facendo perdere di significato al test stesso, che in effetti perde anche altre
delle qualità segnalate nella risposta B4, come ad es. la riproducibilità. Di solito l'ambiente di
esecuzione dei test è di unità risultano sotto lo stretto controllo dello sviluppatore e questa limitazione non risulta
comunque sostanziale.
Come faccio ad essere sicuro che un flusso di esecuzione sia sospeso?
Se non si vogliono scomodare API di dettaglio, come ad esempio quelle utili a verificare lo stato di un processo
o di un thread, che vincolerebbero fortemente alla piattaforma, ed ammesso che il codice di tale flusso sia
nella disponibilità del tester, basta far produrre effetti collaterali visibili dall'esterno subito dopo
l'istruzione bloccante. Il test-case asserirà, per essere sicuro che il flusso testato sia rimasto bloccato,
che non sia riuscito a produrre simili effetti collaterali. Ovviamente nascono anche le problematiche citate
nelle domande C2 e C3.
Come faccio ad essere sicuro che un flusso di esecuzione sia terminato?
E' possibile studiare le API della piattaforma per vedere se
permettono la lettura dello stato del flusso. Più
semplicemente é possibile creare
flussi usa-e-getta ai soli scopi di supporto al
testing che offrano funzionalità adatte
all'interrogazione del loro stato, direttamente, o come
effetti collaterali su strumenti di sincronizzazione
condivisi con il flusso driver (vedi anche
domande C7 e C4).
Ha senso creare flussi di esecuzioni solo a scopi di testing?
Sicuramente. Soprattutto se il testing riguarda proprio codice concorrente.
Quanti flussi di esecuzione creare in un test case?
Il meno possibile, per favorire le qualità del test, come ad esempio la sua semplicità, ed al tempo stesso
un numero sufficiente a mettere in piedi lo scenario di testing
che si intende esercitare. Talvolta il driver del test, ovvero il flusso di esecuzione che
prende in carico l'esecuzione del codice di test, può essere utilizzato come parte attiva
di un sistema di flussi di esecuzione che si intende creare per quello scenario di testing.
Come posso scrivere asserzioni in presenza di codice concorrente che sarà eseguito adottando sequenze di interleaving non prevedibili?
Rinunciando a scrivere asserzioni troppo puntuali. Ad
esempio, per testare uno scenario in cui molteplici
consumatori concorrenti estraggono da uno stesso buffer
unitario inizialmente pieno, si potrebbe verificare che solo
uno vi riesce e tutti gli altri no, ma non è possibile
asserire quale specifico consumatore tra tutti vi riesce. Di
solito questo tipo di scenari hanno senso solo dopo aver
coperto scenari più semplici che permettono
l'utilizzo di asserzioni regolarmente puntuali.
Ci sono altri vantaggi nel fare testing?
Scrivere i test aiuta lo sviluppatore a calarsi nei panni
dell'utilizzatore del codice che sta sviluppando e quindi a
valutarne la facilità di utilizzo. Non è sempre
facile assumere questo punto di vista per chi il codice lo
sta sviluppando e lo conosce nel dettaglio.
Un altro utilizzo sorprendente è per documentazione. Nessuna descrizione informale può documentare la semantica di una funzione o un metodo con la stessa precisione di un test-case funzionante o meno. I test-case scritti soltanto per documentare la semantica di una libreria esterna, sono spesso chiamati Spike.
Curiosamente, alcuni libri di documentazione di librerie di larga diffusione hanno cominciato ad adottare un simile approccio per tutto il testo. Ad esempio Lucene in Action.
Mi risulta faticoso scrivere test di unità in
presenza di codice che utilizza socket, file ed in generale
risorse "esterne" al mio codice principale. Perché e
cosa fare?
Bisognerebbe separare chiaramente il testing
del protocollo utilizzato sulle socket dagli altri
test che di tale protocollo possono far uso. Sebbene questo
non risolve il problema di dover testare entrambi gli
aspetti, la località e la semplicità dei test
risulterebbe migliore.
Quali risorse esterne creare e come devono essere fatte?
Meno possibile. Più semplici possibili, nello stato iniziale e nella dinamica del loro stato.
Rispetto agli homework, quali test unitari aggiungere nelle tesine?
Dipende drasticamente dalla tesina in questione. Ad esempio,
quelle che prevedono la comunicazione remota richiedono il
testing del protocollo di comunicazione. Quelle che hanno
una componente algoritmica da elaborare possono trovare
grande beneficio da test per verificare il corretto
comportamento dell'algoritmo sia in tutti i casi
limite sia in almeno in qualcuno dei casi
ordinari.