risorse | good unit tests

Good Unit Tests

Introduzione

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.

Esempi d'uso

La versione corrente di gut, lungi dall'essere completa, ha raggiunto, per le mie esigenze, un discreto grado di maturità.

Test suite

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.

Controlli

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.

Asserzioni

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!
  // ...
}

Catture

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:

Esito del test

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.

Esito della 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.

Esempio

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);
}

Opzioni e personalizzazioni

Espressioni booleane complesse

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).

fail-fast

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].

Messaggi

È possibile arricchire il prospetto finale di messaggi personalizzati tramite le macro EVAL, INFO, WARN e FAIL:

Prospetti

La decima e la quattordicesima parte degli appunti mostrano come personalizzare il prospetto finale definendo un oggetto Report dedicato.

Punti aperti

Alcune spunti per eventuali sviluppi futuri:

Codice sorgente

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.

Riferimenti

  1. Gli appunti di Good Unit Tests tracciano l'evoluzione nel tempo dell'infrastruttura:
  2. Henney, K. "Rethinking Unit Testing in C++". Skills Matters: ACCU 2010: Rethinking Unit Testing in C++. <http://skillsmatter.com/podcast/agile-testing/kevlin-henney-rethinking-unit-testing-in-c-plus-plus>. Visitato il 20 Marzo 2012.
  3. Llopis, N. "Exploring the C++ Unit Testing Framework Jungle". Games from Within. <http://gamesfromwithin.com/exploring-the-c-unit-testing-framework-jungle>. Visitato il 3 Luglio 2014.
  4. Nash, P. "Catch". philsquared/Catch - github. <https://github.com/philsquared/Catch>. Visitato il 30 Giugno 2014.
  5. Newkirk, J. "Announcing xUnit.net". James Newkirk's blog. <http://jamesnewkirk.typepad.com/posts/2007/09/announcing-xuni.html>. Visitato il 30 Giugno 2014.
  6. Newkirk, J. "Why you should not use SetUp and TearDown in NUnit". James Newkirk's blog. <http://jamesnewkirk.typepad.com/posts/2007/09/why-you-should-.html>. Visitato il 30 Giugno 2012.

Pagina modificata il 29/10/2014