risorse | good unit tests

Good Unit Tests /7

Questa parte (la settima; qui la prima, qui la seconda, qui la terza, qui la quarta, qui la quinta e qui la sesta) rimette in discussione il concetto di TEST_SUITE. Con l'occasione è stata aggiunta l'opzione fail-fast.

Nella terra di nessuno

Il codice che vive all'esterno dei blocchi TEST, se da una parte risulta comodo in quando condiviso dai TEST che lo seguono, ha l'indubbio svantaggio di essere eseguito anche durante la prima invocazione della funzione runTests_, quella che ha il solo scopo di ricavare l'elenco dei test da eseguire nelle passate successive. Questo aspetto, le cui ricadute sulle prestazioni possono essere anche significative – per quanto, trattandosi di unit-test ci si attende che le procedure di setUp/tearDown siano piuttosto rapide – non è l'unico a mettere in discussione l'attuale struttura del codice di test.

TEST_SUITE("suite") {

  f(); // called n + 1 times

  TEST("test") {
    g(); // called once
  }

  //...
}

Eccezioni al di fuori di TEST

Può accadere che eccezioni vengano sollevate al di fuori di un TEST; ad esempio:

#include "gut.h"

TEST_SUITE("suite") {

  setUp(); // may throw

  TEST("test") {
    //...
  }
}

Un'eccezione sollevata in questo contesto causa la terminazione immediata del programma, perché non viene catturata; in particolare, non vengono catturate tutte le eccezioni sollevate durante la prima invocazione della funzione runTests_, quella utilizzata per ricavare l'elenco dei test:

int main() {
  gut::Schedule schedule_;
  runTests_(schedule_); // may throw!
  auto report_ = std::make_shared<gut::DefaultReport>();
  // ...

CHECK/REQUIRE al di fuori di TEST

Sebbene l'uso di CHECK e REQUIRE all'esterno di un TEST sia legittima – il codice prodotto dall'espansione delle macro è sintatticamente corretto – la loro presenza dovrebbe essere limitata al loro interno, in parte perché potenziali sorgenti di eccezioni, in secondo luogo per favorire una maggiore struttrazione del codice di test. Non è però banale adattare l'attuale infrastruttura di test in modo tale da causare un errore di compilazione in seguito all'uso delle macro in un contesto inappropriato. È relativamente facile desumere la posizione interna/esterna di un fallimento (gut::Report::failure invocata senza una precedente chiamata a gut::Report::startTest), più difficile è farlo per i controlli che si concludono con esito positivo.

Conviene allora modificare radicalmente il modello attuale, e passare ad uno alternativo; in particolare, si separano le due fasi di registrazione ed esecuzione dei test; la prima è a carico dell'oggetto Suite, mentre della seconda se ne occupa ancora la funzione runTests_, ma con una modalità diversa:

struct TestSelection {
  virtual bool shouldRun(const std::string& testName) = 0;
};

struct Schedule : public TestSelection {
  std::vector<std::string> testNames;
  size_t size() const {
    return testNames.size();
  }
  virtual bool shouldRun(const std::string& testName) {
    testNames.push_back(testName);
    return false;
  }
};

struct SingleTestSelection : public TestSelection {
  std::string testName_;
  SingleTestSelection(const std::string& testName) : testName_(testName) { }
  virtual bool shouldRun(const std::string& testName) {
    return testName == testName_;
  }
};

// ...

class Report {
  // ...
public:
  static void start(const std::string& label) {
    if (report_)
      report_->onStart(label);
  }
  // ...
protected:
  virtual void onStart(const std::string& /*label*/) { }
  // ...
};

class DefaultReport : public gut::Report {
  // ...
protected:
  virtual void onStart(const std::string& label) {
    std::cout << "Testing " << label << "..." << std::endl;
    std::cout << "Test suite started..." << std::endl;
  }
  // ...
};

typedef void (*TestFn)();

class Test {
  std::string name_;
  TestFn test_;
public:
  Test(const std::string& name, TestFn test) : name_(name), test_(test) {
  }
  const std::string& name() const {
    return name_;
  }
  void run() {
    test_();
  }
};

class Suite {
  static std::vector<Test> tests_;
public:
  struct add {
    add(const std::string& name, TestFn test) {
      tests_.push_back(Test(name, test));
    }
  };
  static const std::vector<Test>& tests() {
    return tests_;
  }
};

std::vector<Test> Suite::tests_;

#define MAKE_UNIQUE(name_) CONCAT_(name_, __LINE__)

#define TEST(name_) \
  if (selection_.shouldRun(name_))
  static void MAKE_UNIQUE(test_)(); \
  gut::Suite::add MAKE_UNIQUE(testAddition_)(name_, &CONCAT_(test_, __LINE__)); \
  static void MAKE_UNIQUE(test_)()

// replace the old TEST_SUITE macro with this fragment:
int runTests_() {
  auto report_ = std::make_shared<gut::DefaultReport>();
  gut::Report::set(report_);
  gut::Report::start();
  for (auto test : gut::Suite::tests()) {
    gut::Report::startTest(test.name());
    try {
      test.run();
    } catch(const gut::AbortTest&) {
    } catch(const std::exception& e_) {
      gut::Report::failure(gut::UnexpectedExceptionFailure(e_, __FILE__, __LINE__));
    } catch(...) {
      gut::Report::failure(gut::UnknownExceptionFailure(__FILE__, __LINE__));
    }
    gut::Report::endTest();
  }
  gut::Report::end();
  return report_->failedTestCount();
}

#ifndef GUT_CUSTOM_MAIN
int main() {
  return runTests_();
}
#endif

Ogni TEST origina una funzione, il cui nome è reso univoco appendendo al prefisso test_ il numero di linea corrente; il test viene quindi registrato tramite l'istanziazione dell'oggetto di comodo Suite::add il quale provvede ad inserire la funzione di test in un vettore statico che verrà successivamente scansito dalla procedura runTests_.

Venendo meno la macro TEST_SUITE, il codice di test assume una forma più essenziale:

TEST_SUITE("RecentlyUsedList") {

  TEST("Initial list is empty") {
    RecentlyUsedList anEmptyList;

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

// ...

}

main non è più definita nella macro TEST_SUITE, ma è direttamente disponibile una volta incluso l'header gut.h. Poiché questo può a volte essere indesiderabile, la macro GUT_CUSTOM_MAIN funge da guardia. Un esempio del suo utilizzo è disponibile nel test-case dell'infrastruttura, ove è già presente una versione locale della funzione main:

#define GUT_CUSTOM_MAIN
#include "gut.h"
// ...
int main() {

  std::string lastFailure;
  gut::Report::set(std::make_shared<LastFailure>(lastFailure));
  // ...
  return 0;
}

Fail-Fast

La macro REQUIRE causa l'interruzione del TEST corrente; manca tuttavia la possibilità di interrompere l'esecuzione dell'intera suite in corrispondenza del primo fallimento. L'opzione fail-fast ha proprio questo scopo:

#include "gut.h"

TEST("fail-fast /1") {
  CHECK(1 == 2);
  CHECK(2 == 3);
}

TEST("fail-fast /2") {
  CHECK(1 == 2);
  CHECK(2 == 3);
}

/* output:
 *
 * Test suite started...
 * fail-fast /1: FAILED
 *  failfast.cpp(4) : [error] 1 == 2 evaluates to 1 == 2
 *  failfast.cpp(5) : [error] 2 == 3 evaluates to 2 == 3
 * fail-fast /2: FAILED
 *  failfast.cpp(9) : [error] 1 == 2 evaluates to 1 == 2
 *  failfast.cpp(10) : [error] 2 == 3 evaluates to 2 == 3
 * Ran 2 test(s) in 0s.
 * FAILED - 4 failure(s) in 2 test(s)
 */

La richiesta di un'uscita anticipata è gestita dall'oggetto FailFast:

// ...

struct AbortSuite { };

class FailFast {
  static bool enabled_;
public:
  FailFast() {
    enabled_ = true;
  }
  static bool enabled() {
    return enabled_;
  }
};

bool FailFast::enabled_ = false;

#ifdef GUT_FAILFAST
FailFast failFast_;
#endif

// ...

class Report {
  // ...
  static void failure(const Failure& failure) {
    if (report_)
      report_->onFailure(failure);
    if (FailFast::enabled())
      throw AbortSuite();
  }
  static void quit() {
    if (report_)
      report_->onQuit();
  }
  // ...
  virtual void onFailure(const Failure& /*failure*/) { }
  virtual void onQuit() { }
};

class DefaultReport : public Report {
  // ...
  virtual void onQuit() {
    ++failedTests_;
  }
}

// ...

int runTests_() {
    // ...
    try {
      test.run();
    } catch(const gut::AbortSuite&) {
      gut::Report::quit();
      break;
    } catch(const gut::AbortTest&) {
    // ...

L'opzione fail-fast si attiva definendo la macro GUT_FAILFAST prima dell'inclusione dell'infrastruttura di test:

#define GUT_FAILFAST
#include "gut.h"

TEST("fail-fast /1") {
  CHECK(1 == 2);
  CHECK(2 == 3);
}

TEST("fail-fast /2") {
  CHECK(1 == 2);
  CHECK(2 == 3);
}

/* output:
 *
 * Test suite started...
 * fail-fast /1: FAILED
 *  failfast.cpp(5) : [error] 1 == 2 evaluates to 1 == 2
 * Ran 1 test(s) in 0.015s.
 * FAILED - 1 failure(s) in 1 test(s)
 */

Codice sorgente

Pagina modificata il 15/01/2013