risorse | good unit tests
gut (acronimo di Good Unit Tests) è una piccola infrastruttura di test che sto sviluppando per il test di software C++ in ambiente Windows 32/64 bit con Visual Studio 2008/2012 e MinGW/GCC 4.8.2. Come molti che per una ragione o l'altra non hanno potuto avvalersi di una libreria di test, tutto ebbe inizio da un'assert:
#include <cassert> // my first test case int main() { int a = 0; assert(a == 1); // boom! }
Ben presto una macro prese il posto della meno pratica assert:
#include <iostream> #define TEST(test_) \ do { \ if (!(test_)) \ std::cerr << __FILE__ << "(" << __LINE__ << ") : TEST error: " << #test_ << std::endl; \ } while (0) // my first test case int main() { int a = 0; TEST(a == 1); } /* output: * * test.cpp(14) : TEST error: a == 1 */
Da questa forma molto primitiva, traendo ispirazione da ciò che si trovava sulla rete (l'analisi di Noel Llopis[3] in primis, ma in particolare l'intervento di Kevlin Henney a ACCU 2010[2] e la libreria catch[4]), gut nel tempo si è evoluta; negli appunti[1] ho tenuto traccia delle varie versioni che si sono succedute.
La versione corrente di gut, lungi dall'essere completa, ha raggiunto, per le mie esigenze, un discreto grado di maturità.
Una test suite è un file *.cpp contenente uno o più TEST:
[file test.cpp] #include "gut.h" TEST("A test") { // ... } TEST("A second test") { // ... } /* output: * * Test suite started... * A test: OK * A second test: OK * Ran 2 test(s) in 0s. * OK - all tests passed. */
Ogni test è caratterizzato da un nome, che viene riportato nel prospetto finale, corredato delle indicazioni riguardanti l'esito del test stesso, al momento uno tra OK e FAILED. L'ultima riga del prospetto fornisce l'esito finale dell'intera test suite.
I test sono costituiti da istruzioni di controllo, che possono essere di tue tipi: asserzioni, il cui scopo è appurare la veridicità di un'espressione booleana, e catture, che si occupano invece di verificare la presenza di eccezioni.
Esistono due tipi di asserzioni: CHECK e REQUIRE. Entrambe si applicano ad una espressione booleana, ed entrambe causano il fallimento del test nel caso il valore di verità sia false; la seconda causa inoltre la terminazione anticipata del test corrente.
#include "gut.h" TEST("Assertions") { int a = 1; int b = 2; int c = 3; CHECK(a == 0); REQUIRE(b == 3); CHECK(c == 1); // won't be executed } /* output: * * Test suite started... * Assertions: FAILED * test.cpp(9) : [error] a == 0 evaluates to 1 == 0 * test.cpp(10) : [fatal] b == 3 evaluates to 2 == 3 * Ran 1 test(s) in 0s. * FAILED - 2 failure(s) in 1 test(s). */
Per ogni asserzione fallita nel prospetto viene indicato, oltre alla sua posizione all'interno del codice sorgente, l'espressione originale e gli effettivi valori in gioco. In particolare, il fallimento dell'asserzione CHECK viene riportato come error, quello di REQUIRE come fatal, a sottolineare il fatto che l'esecuzione del test è stata interrotta in quel punto, per proseguire col successivo.
Preferire l'uso di CHECK a REQUIRE, per evidenziare fin da subito tutti i casi d'errore (REQUIRE interrompe l'esecuzione del test in corrispodenza del primo). Limitare l'uso di REQUIRE ai casi in cui non è possibile/conveniente proseguire nel test, come ad esempio nel caso non sia stato possibile acquisire una risorsa (es. per verificare l'effettiva apertura di un file):
// ... TEST("CHECK vs. REQUIRE") { std::ifstream file("file.txt"); CHECK(file.good()); // not sure if file is good REQUIRE(file.good()); // if we got here, the file is certainly good! // ... }
Il controllo sulle eccezioni avviene tramite la famiglia di clausole che si applicano all'espressione che ci si aspetta sollevi l'eccezione; THROWS ad esempio, verifica che venga sollevata un'eccezione di un tipo predefinito:
#include "gut.h" TEST("Assertions") { int a = 1; int b = 2; int c = 3; CHECK(a == 0); REQUIRE(b == 3); CHECK(c == 1); // won't be executed } void fnThatNotThrows() { } int fnThatThrowsARuntimeError() { throw std::runtime_error("a runtime error"); } int fnThatThrowsAnInt() { throw 42; } TEST("Captures") { THROWS(fnThatNotThrows(), std::runtime_error); THROWS(fnThatThrowsARuntimeError(), std::logic_error); THROWS(fnThatThrowsAnInt(), std::runtime_error); } /* output: * * Test suite started... * Assertions: FAILED * test.cpp(9) : [error] a == 0 evaluates to 1 == 0 * test.cpp(10) : [fatal] b == 3 evaluates to 2 == 3 * Captures: FAILED * test.cpp(27) : [error] fnThatNotThrows() did not throw * test.cpp(28) : [error] fnThatThrowsARuntimeError() threw an unexpected exception "a runtime error" * test.cpp(29) : [error] fnThatThrowsAnInt() threw an unknown exception * Ran 2 test(s) in 0s. * FAILED - 5 failure(s) in 2 test(s). */
Sono disponibili quattro modalità di cattura:
Il sollevamento di un'eccezione al di fuori di una clausola THROWS causa il fallimento immediato del test, alla stregua di una REQUIRE fallita, fornendo come posizione dell'errore, per forza di cose, un riferimento all'implementazione di gut di dubbia utilità:
#include "gut.h" TEST("Assertions") { int a = 1; int b = 2; int c = 3; CHECK(a == 0); REQUIRE(b == 3); CHECK(c == 1); // won't be executed } void fnThatNotThrows() { } int fnThatThrowsARuntimeError() { throw std::runtime_error("a runtime error"); } int fnThatThrowsAnInt() { throw 42; } TEST("Captures") { THROWS(fnThatNotThrows(), std::runtime_error); THROWS(fnThatThrowsARuntimeError(), std::logic_error); THROWS(fnThatThrowsAnInt(), std::runtime_error); CHECK(fnThatThrowsAnInt() == 0); fnThatNotThrows(); // won't be executed } /* output: * * Test suite started... * Assertions: FAILED * test.cpp(9) : [error] a == 0 evaluates to 1 == 0 * test.cpp(10) : [fatal] b == 3 evaluates to 2 == 3 * Captures: FAILED * test.cpp(27) : [error] fnThatNotThrows() did not throw * test.cpp(28) : [error] fnThatThrowsARuntimeError() threw an unexpected exception "a runtime error" * test.cpp(29) : [error] fnThatThrowsAnInt() threw an unknown exception * gut.h(907) : [fatal] unknown exception caught * Ran 2 test(s) in 0s. * FAILED - 6 failure(s) in 2 test(s). */
La natura delle macro THROWS è affine all'asserzione CHECK, nel senso che il loro fallimento non causa l'interruzione del test. È disponibile una forma più “drastica” delle stesse, che in caso d'errore sospendono l'esecuzione del test; si distinguono da quelle convenzionali per il prefisso REQUIRE_, per analogia con l'omonima asserzione:
Il fallimento di un'asserzione o di una cattura causa il fallimento del test (oltre al già citato caso di un'eccezione inaspettata), che viene perciò classificato come FAILED. Alla stessa stregua, il fallimento di un test causa a cascata il fallimento dell'intera test suite.
Una test suite viene considerata conclusa con successo (e conseguentemente marcata OK) solo se tutti i test al suo interno si sono conclusi con successo; in caso contrario, viene classificata FAILED.
Quel che segue è un esempio di test suite per una lista MRU – Most Recently Used di stringhe. Il file rappresenta una sorta di specifica, per ogni aspetto del funzionamento richiesto è infatti stato predisposto un test apposito. I test condividono la stessa struttura: predisposizione dell'ambiente (il given del BDD – Behaviour-driven development, o il setup di xUnit), attivazione della funzionalità (when/exercise) e verifica dell'effetto (then/verify – il teardown previsto da xUnit è qui implicito).
#include "gut.h" #include "recently-used-list.h" TEST("Initial list is empty") { RecentlyUsedList anEmptyList; CHECK(anEmptyList.empty()); CHECK(anEmptyList.size() == 0); } TEST("Insertion to empty list is retained") { RecentlyUsedList aListWithOneElement; aListWithOneElement.insert("one"); CHECK(!aListWithOneElement.empty()); CHECK(aListWithOneElement.size() == 1); CHECK(aListWithOneElement[0] == "one"); } TEST("Distinct insertions are retained in stack order") { RecentlyUsedList aListWithManyElements; aListWithManyElements.insert("one"); aListWithManyElements.insert("two"); aListWithManyElements.insert("three"); CHECK(!aListWithManyElements.empty()); CHECK(aListWithManyElements.size() == 3); CHECK(aListWithManyElements[0] == "three"); CHECK(aListWithManyElements[1] == "two"); CHECK(aListWithManyElements[2] == "one"); } TEST("Duplicate insertions are moved to the front but not inserted") { RecentlyUsedList aListWithDuplicatedElements; aListWithDuplicatedElements.insert("one"); aListWithDuplicatedElements.insert("two"); aListWithDuplicatedElements.insert("three"); aListWithDuplicatedElements.insert("two"); CHECK(!aListWithDuplicatedElements.empty()); CHECK(aListWithDuplicatedElements.size() == 3); CHECK(aListWithDuplicatedElements[0] == "two"); CHECK(aListWithDuplicatedElements[1] == "three"); CHECK(aListWithDuplicatedElements[2] == "one"); } TEST("Out of range indexing throws exception") { RecentlyUsedList aListWithOneElement; aListWithOneElement.insert("one"); THROWS(aListWithOneElement[1], std::out_of_range); }
A fronte del fallimento di un'espressione booleana non relazionale (che non coinvolga cioé un operatore tipo ==, <, >=, …) la macro CHECK si limita a indicare la presenza di un inatteso false:
#include "gut.h" bool isOdd(int n) { return n % 2 == 1; } TEST("Custom boolean expression") { int i = 4; CHECK(isOdd(i)); } /* output: * * Test suite started... * Custom boolean expression: FAILED * prova.cpp(9) : [error] isOdd(i) evaluates to false // what?! * Ran 1 test(s) in 0s. * FAILED - 1 failure(s) in 1 test(s). */
È possibile tuttavia ottenere informazioni più dettagliate riscrivendo la funzione isOdd, in questo modo:
#include "gut.h"bool isOdd(int n) { return n % 2; }gut::Boolean isOdd(int n) { return gut::Boolean(n % 2 == 1) << n << " mod 2 != 0"; } TEST("Custom boolean expression") { int i = 4; CHECK(isOdd(i)); } /* output: * * Test suite started... * Custom boolean expression: FAILED * prova.cpp(9) : [error] isOdd(i) evaluates to 4 mod 2 != 0 * Ran 1 test(s) in 0s. * FAILED - 1 failure(s) in 1 test(s). */
In sostanza, anziché ritornare un semplice bool, la funzione restituisce un oggetto di tipo gut::Boolean inizializzato con il valore di verità originale, corredato dall'eventuale messaggio d'errore – che apparirà nel prospetto in caso il valore di ritorno sia false. Il messaggio viene composto incrementalmente, serializzando le varie componenti direttamente nell'oggetto Boolean tornato. Esempi più complessi (AreAlmostEqual per l'uguaglianza di numeri floating-point, AreTextFileEqual che confronta due file di testo evidenziando la prima differenza trovata) sono riportati nella quarta parte degli appunti (la tredicesima parte presenta un piccolo refactoring della classe Boolean di cui gli esempi non tengono conto).
L'opzione fail-fast termina anticipatamente l'intera test suite in corrispondenza del primo errore. Per attivarla, è sufficiente definire la macro GUT_FAILFAST immediatamente dopo la direttiva di inclusione di gut[1.7], [1.8].
È possibile arricchire il prospetto finale di messaggi personalizzati tramite le macro EVAL, INFO, WARN e FAIL:
La decima e la quattordicesima parte degli appunti mostrano come personalizzare il prospetto finale definendo un oggetto Report dedicato.
Alcune spunti per eventuali sviluppi futuri:
Fare riferimento agli appunti più recenti per accedere alla versione aggiornata dei sorgenti.
[Aggiornamento 09/06/2018]
I sorgenti sono ora disponibili all'indirizzo https://github.com/gzuliani/gut.
Pagina modificata il 29/10/2014