risorse | good unit tests

Good Unit Tests /3

La prima parte di Good Unit Tests è incentrata sulla valutazione e la corretta rappresentazione dei valori citati nella macro CHECK; la seconda parte parte introduce la macro THROWS per la cattura delle eccezioni. Questa terza parte affronta il problema della gestione delle segnalazioni di fallimento.

Failure

Il primo passo da compiere consiste nel tipizzare il tipo di fallimento registrato:

struct Location {
  const char* file;
  int line;
  Location(const char* file_, int line_) : file(file_), line(line_) {
  }
};

struct Failure {
protected:
  std::ostringstream description;
public:
  Location location;
  Failure(const char* file, int line) : location(file, line) {
  }
  std::string what() const {
    return description.str();
  }
};

struct CheckFailure : public Failure {
  CheckFailure(const char* expression, const std::string& expansion, const char* file, int line) : Failure(file, line) {
    description << expression << " evaluates to " << expansion;
  }
};

struct NoThrowFailure : public Failure {
  NoThrowFailure(const char* expression, const char* file, int line) : Failure(file, line) {
    description << expression << " did not throw";
  }
};

struct WrongTypedExceptionFailure : public Failure {
  WrongTypedExceptionFailure(const char* expression, const std::exception& exception, const char* file, int line) : Failure(file, line) {
    description << expression << " threw an unexpected exception \"" << exception.what() << "\"";
  }
};

struct WrongExceptionFailure : public Failure {
  WrongExceptionFailure(const char* expression, const char* file, int line) : Failure(file, line) {
    description << expression << " threw an unknown exception";
  }
};

struct UnexpectedExceptionFailure : public Failure {
  UnexpectedExceptionFailure(const std::exception& exception, const char* file, int line) : Failure(file, line) {
    description << "unexpected exception \"" << exception.what() << "\" caught";
  }
};

struct UnknownExceptionFailure : public Failure {
  UnknownExceptionFailure(const char* file, int line) : Failure(file, line) {
    description << "unknown exception caught";
  }
};

Le specifiche tipologie di fallimento sono oggetti di tipo Failure, e hanno la responsabilità/opportunità di serializzare, nel proprio costruttore, una descrizione testuale nel membro description. Il contesto del fallimento è rappresentato dal membro location, che contiene il percorso al file sorgente e il numero di linea ove il fallimento è stato registrato.

Report

L'oggetto Report ha lo scopo di disaccoppiare l'istanziazione di un oggetto Failure con la notifica di tale evento all'utente:

class Report {
  static std::shared_ptr<Report> report_;
public:
  static void set(std::shared_ptr<Report> report) {
    report_ = report;
  }
  static void failure(const Failure& failure) {
    if (report_)
      report_->failure(failure);
  }
protected:
  virtual void onFailure(const Failure& failure) { }
};

std::shared_ptr<Report> Report::report_;

La responsabilità di Report è di dirottare la notifica dell'avvenuto fallimento al Report concreto, che la gestirà nel modo più opportuno.

Le macro CHECK e THROWS diventano:

#define CHECK(expr_) \
  do { \
    if (!(Capture()->*expr_)) \
      Report::failure(CheckFailure(#expr_, Expression::last, __FILE__, __LINE__)); \
  while (0)

#define THROWS(expr_, exception_) \
  do { \
    try { \
      (void)(expr_); \
        Report::failure(NoThrowFailure(#expr_, __FILE__, __LINE__)); \
    } catch(const exception_&) { \
    } catch(const std::exception& e) { \
      Report::failure(WrongTypedExceptionFailure(#expr_, e, __FILE__, __LINE__)); \
    } catch(...) { \
      Report::failure(WrongExceptionFailure(#expr_, __FILE__, __LINE__)); \
    } \
  while (0)

Similmente, il blocco try/catch principale diventa:

  // ...
  } catch(const std::exception& e) {
    Report::failure(UnexpectedExceptionFailure(e, __FILE__, __LINE__));
  } catch(...) {
    Report::failure(UnknownExceptionFailure(__FILE__, __LINE__));
  }

A titolo d'esempio, un Report che serializza su std::cout i fallimenti alla stessa stregua di quanto faceva la versione “diretta” è lo StdErrReport seguente:

struct StdErrReport : public Report {
protected:
  virtual void onFailure(const Failure& failure) {
    std::cout << failure.location.file << "(" << failure.location.line << ") : " << failure.what() << std::endl;
  }
};

// ...

// log to std::cout
Report::set(std::make_shared<StdErrReport>());

Diventa ora possibile verificare puntualmente la cattura dei fallimenti:

// ...
#include <cassert>

class LastFailure : public Report {
  std::string& what_;
  LastFailure& operator=(const LastFailure&); // disabled!
public:
  LastFailure(std::string& what) : what_(what) {
  }
protected:
  virtual void onFailure(const Failure& failure) {
    what_ = failure.what();
  }
};

// ...

int main() {

  std::string lastFailure;
  Report::set(std::make_shared<LastFailure>(lastFailure));

  // ints
  int i1 = 1;
  int i2 = 2;
  CHECK(i1 == i2);
  assert(lastFailure == "i1 == i2 evaluates to 1 == 2");

  // ...
}

Zucchero sintattico

Giunti a questo punto, è ora di mettere alla prova l'infrastruttura di test, e renderne l'uso agevole quanto basta. A titolo d'esempio, una possibile implementazione della classica RecentlyUsedList spesso citata da Henney, seguita dal suo test:

#include "gut.h"
#include <algorithm>
#include <stdexcept>
#include <vector>

class RecentlyUsedList {
  std::vector<std::string> items_;
public:
  bool empty() const {
    return items_.empty();
  }
  size_t size() const {
    return items_.size();
  }
  std::string operator[] (size_t index) const {
    if (index >= size())
      throw std::out_of_range("invalid subscript");
    return items_[size() - index - 1];
  }
  void insert(const std::string& item) {
    auto found = std::find(begin(items_), end(items_), item);
    if (found != items_.end())
      items_.erase(found);
    items_.push_back(item);
  }
};

int main() {

  // initial list is empty
  {
    RecentlyUsedList anEmptyList;

    CHECK(anEmptyList.empty());
    CHECK(anEmptyList.size() == 0);
  }

  // insertion to empty list is retained
  {
    RecentlyUsedList aListWithOneElement;
    aListWithOneElement.insert("one");

    CHECK(!aListWithOneElement.empty());
    CHECK(aListWithOneElement.size() == 1);
    CHECK(aListWithOneElement[0] == "one");
  }

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

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

  // out of range indexing throws exception
  {
    RecentlyUsedList aListWithOneElement;
    aListWithOneElement.insert("one");

    THROWS(aListWithOneElement[1], std::out_of_range);
  }

  return 0;
}

Nota: secondo la classificazione di Henney, il test è strutturato come una specifica: non è monolitico[1] nel senso che non è una sequenza arbitraria di chiamate a metodi seguiti da altrettanto arbitrarie verifiche dello stato dell'oggetto, e non è procedurale[2] nel senso che i test non sono in corrispondenza biunivoca con i metodi dell'interfaccia.

L'esecuzione del test dovrebbe produrre uno 0 (a indicare successo) e un messaggio adeguato, qualcosa come «All tests passed». In caso di errore, invece, dovrebbe tornare un valore diverso da 0 (a indicare il fallimento di almeno un test), oltre alle indicazioni circa le condizioni d'errore verificate. Il conteggio dei test falliti è assegnato ad un Report apposito:

class ReportWithFailureCount : public Report {
  size_t count_;
public:
  size_t count() const {
    return count_;
  }
  ReportWithFailureCount() : count_(0) { }
protected:
  virtual void onFailure(const Failure& failure) {
    std::cout << failure.location.file << "(" << failure.location.line << ") : " << failure.what() << std::endl;
    ++count_;
  }
};

// ...

int main() {

  auto report = std::make_shared<ReportWithFailureCount>();
  Report::set(report);

  // ...

  return 0;

  if (report->count() == 0)
    std::cout << "All tests passed." << std::endl;
  else
    std::cout << report->count() << " test(s) failed." << std::endl;

  return report->count();
}

Modificando alcune condizioni di test si verifica facilmente il buon funzionamento del test case.

Conviene tuttavia lasciare a report la responsabilità di emettere l'esito finale del test; cosa facilmente realizzabile, se gli si notifica la conclusione delle verifiche:

class Report {
  static std::shared_ptr<Report> report_;
public:
  static void set(std::shared_ptr<Report> report) {
    report_ = report;
  }
  static void start() {
    if (report_)
      report_->onStart();
  }
  static void end() {
    if (report_)
      report_->onEnd();
  }
  static void failure(const Failure& failure) {
    if (report_)
      report_->onFailure(failure);
  }
protected:
  virtual void onStart() { }
  virtual void onEnd() { }
  virtual void onFailure(const Failure& /*failure*/) { }
};

// ...

class ReportWithFailureCount : public Report {
  // ...
protected:
  // ...
  virtual void onEnd() {
    if (count_ == 0)
      std::cout << "All tests passed." << std::endl;
    else
      std::cout << count_ << " test(s) failed." << std::endl;
  }
};

// ...

int main() {

  auto report = std::make_shared<ReportWithFailureCount>();
  Report::set(report);

  Report::start();

  // ...

  if (report->count() == 0)
    std::cout << "All tests passed." << std::endl;
  else
    std::cout << report->count() << " test(s) failed." << std::endl;

  Report::end();

  return report->count();
}

Arricchendo il report di un “contaminuti”, è banale fornire una stima circa la durata del test:

#include <chrono>

class Clock {
  std::chrono::steady_clock::time_point start_;
public:
  Clock() { start_ = std::chrono::steady_clock::now(); }
  double elapsedTime() {
    return std::chrono::duration_cast<std::chrono::milliseconds>(
      std::chrono::steady_clock::now() - start_).count() / 1000.;
  }
};

/* alternative implementation, based on clock(),
 * suitable if the <chrono> header is not available
 *
 * struct Clock {
 *   clock_t start_;
 *   Clock() { start_ = clock(); }
 *   double elapsedTime() {
 *     return static_cast<double>(clock() - start_) / CLOCKS_PER_SEC;
 *   }
 * };
 */

class Report {
  static std::shared_ptr<Report> report_;
protected:
  Clock clock_;
  // ...
};

class ReportWithFailureCount : public gut::Report {
  // ...
  virtual void onEnd() {
    std::cout << "Test suite ran in " << clock_.elapsedTime() << "s." << std::endl;
    if (count_ == 0)
      std::cout << "All tests passed." << std::endl;
    else
      std::cout << count_ << " test(s) failed." << std::endl;
  }
};

// ...

Se infine tutto il test viene spostato all'interno di una funzione di comodo runTest, il programma principale assume la forma:

void runTest();

int main() {

  auto report = std::make_shared<ReportWithFailureCount>();
  gut::Report::set(report);

  gut::Report::start();

  try {
    runTest();
  } catch(const std::exception& e) {
    Report::failure(UnexpectedExceptionFailure(e, __FILE__, __LINE__));
  } catch(...) {
    Report::failure(UnknownExceptionFailure(__FILE__, __LINE__));
  }

  gut::Report::end();

  return report->count();
}

void runTest() {
  // test code here!!!
}

Rinominando ReportWithFailureCount in DefaultReport e definendo la macro TEST come segue:

#define TEST \
  void runTest(); \
  int main() { \
    auto report = std::make_shared<DefaultReport>(); \
    Report::set(report); \
    Report::start(); \
    try { \
      runTest(); \
    } catch(const std::exception& e) { \
      Report::failure(UnexpectedExceptionFailure(e, __FILE__, __LINE__)); \
    } catch(...) { \
      Report::failure(UnknownExceptionFailure(__FILE__, __LINE__)); \
    } \
    Report::end(); \
    return report->count(); \
  } \
  void runTest()

il test case si riduce alla forma:

#include "gut.h"

class RecentlyUsedList {
  // ...
};

TEST {
  // initial list is empty
  {
    RecentlyUsedList anEmptyList;

    CHECK(anEmptyList.empty());
    CHECK(anEmptyList.size() == 0);
  }
  // ...
}

namespace

Per evitare spiacevoli collisioni, tutte le funzioni e gli oggetti di supporto sono stati inseriti nel namespace gut.

Sviluppi futuri

SPECIFICATION, PROPOSITION, DIVISION
introdurle solo per strutturare il test mi sembra una forzatura: un po' di disciplina è sufficiente per non cadere nel monolitico o nel procedurale (cfr. ad esempio RecentlyUsedList); anche a livello di diagnostica non mi sembrano così fondamentali – le coordinate del fallimento sono disponibili nel messaggio d'errore, e qualunque editor di testo è in grado di interpretarle e portare il cursore in posizione –; potrebbe però risultare utile come filtro, per ignorare temporaneamente determinate sezioni (con un meccanismo che sfrutti la linea di comando);
REQUIRE
le macro CHECKe THROW, a fronte di un fallimento, non causano l'arresto del test; in certi casi potrebbe invece essere auspicabile interrompere immediatamente il test a fronte di una condizione non soddisfatta (ad esempio per l'indisponibilità di risorse indispensabili al prosieguo del test);
PRINT
avendo predisposto la cattura dei parametri, potrebbe essere utile a volte visualizzare il valore di una variabile;
descrizioni estese per le espressioni booleane
indagare su come potrebbero essere trattati casi di oggetti convertibili a bool. Penso a casi del tipo:
  CHECK(AreAlmostEqual(float1, float2));
  // ...
  CHECK(AreTextFilesEqual(path1, path2));
  // ...
ove le funzioni Are…Equal tornano un oggetto complesso convertibile a booleano (che vale true se e solo se il criterio d'eguaglianza è soddisfatto) ma che una volta “catturato” genera una rappresentazione testuale della disuguaglianza (evidenziando ad esempio la distanza dei due valori numerici nel primo caso, la prima non-corrispondenza tra i due file nel secondo).

Appendice

Esempio di test monolitico: la sequenza delle chiamate, per quanto possa seguire una certa logica, questa è del tutto arbitraria e non necessariamente evidente; il funzionamento dell'oggetto sotto test non è esplicito, ma va dedotto analizzando attentamente il codice di test.

// ...

int main() {
  // ...
  RecentlyUsedList aList;
  CHECK(aList.empty());
  CHECK(aList.size() == 0);

  aList.insert("one");
  CHECK(!aList.empty());
  CHECK(aList.size() == 1);
  CHECK(aList[0] == "one");

  aList.insert("two");
  CHECK(!aList.empty());
  CHECK(aList.size() == 2);
  CHECK(aList[0] == "two");
  CHECK(aList[1] == "one");

  aList.insert("three");
  CHECK(!aList.empty());
  CHECK(aList.size() == 3);
  CHECK(aList[0] == "three");
  CHECK(aList[1] == "two");
  CHECK(aList[2] == "one");

  aList.insert("two");
  CHECK(!aList.empty());
  CHECK(aList.size() == 3);
  CHECK(aList[0] == "two");
  CHECK(aList[1] == "three");
  CHECK(aList[2] == "one");

  std::cout << "All tests passed." << std::endl;
  return 0;
}

Esempio di test procedurale: ogni metodo viene testato in “isolamento”. Ipotesi chiaramente falsa: per verificare il buon funzionamento del metodo empty, per esempio, è indispensabile effettuare almeno una chiamata insert (oltre a quella del costruttore, ovviamente).

// ...

int main() {
  // ...
  // empty
  {
    RecentlyUsedList aList;
    CHECK(aList.empty());

    aList.insert("one");
    CHECK(!aList.empty());
  }

  // size
  {
    RecentlyUsedList aList;
    CHECK(aList.size() == 0);

    aList.insert("one");
    CHECK(aList.size() == 1);

    aList.insert("two");
    CHECK(aList.size() == 2);
  }

  // insert
  {
    RecentlyUsedList aList;

    aList.insert("one");
    CHECK(aList[0] == "one");

    aList.insert("two");
    CHECK(aList[0] == "two");
    CHECK(aList[1] == "one");

    aList.insert("three");
    CHECK(aList[0] == "three");
    CHECK(aList[1] == "two");
    CHECK(aList[2] == "one");

    aList.insert("two");
    CHECK(aList[0] == "two");
    CHECK(aList[1] == "three");
    CHECK(aList[2] == "one");
  }

  // operator[]
  {
    RecentlyUsedList aList;
    aList.insert("one");

    THROWS(aList[1], std::out_of_range);
  }

  std::cout << "All tests passed." << std::endl;
  return 0;
}

Codice sorgente

Pagina modificata il 03/10/2012