risorse | good unit tests

Good Unit Tests /10

Questa parte (la decima; qui la prima, qui la seconda, qui la terza, qui la quarta, qui la quinta, qui la sesta, qui la settima, qui l'ottava e qui la nona) è dedicata alla definizione di report polimorfici che non discendano dall'oggetto gut::Report (cfr. polimorfismo senza ereditarietà).

Introduzione

Senza la derivazione dalla classe base, la definizione del report di test diventa:

// test-gut.cpp
...

class TestReport : public gut::Report {
  std::string& failure_;
  std::string& eval_;
  std::string& info_;
  std::string& warn_;
  // disabled!
  TestReport(const TestReport&);
  TestReport& operator=(const TestReport&);
public:
  TestReport(
    std::string& failure,
    std::string& eval,
    std::string& info,
    std::string& warn)
  : failure_(failure)
  , eval_(eval)
  , info_(info)
  , warn_(warn) {
  }
protected:
  void onStart() { }
  void onEnd() { }
  void onStartTest(const std::string& /*name*/) { }
  void onEndTest() { }
  virtual void onFailure(const gut::Notice& failure) { failure_ = failure.what(); }
  virtual void onEval(const gut::Notice& eval) { eval_ = eval.what(); }
  virtual void onInfo(const gut::Notice& info) { info_ = info.what(); }
  virtual void onWarn(const gut::Notice& warn) { warn_ = warn.what(); }
  void onQuit() { }
  int failedTestCount() { return -1; }
};

L'interfaccia non è più virtuale, ma implicita. In questo modo, qualunque oggetto può essere utilizzato come report, purché implementi i seguenti metodi:

  // implicit Report interface
  void onStart();
  void onEnd();
  void onStartTest(const std::string& name);
  void onEndTest();
  void onFailure(const Notice& failure);
  void onEval(const Notice& eval);
  void onInfo(const Notice& info);
  void onWarn(const Notice& warn);
  void onQuit();
  int failedTestCount();

L'istanziazione del nuovo report di test cambia di conseguenza:

...
int main() {

  std::string lastFailure;
  std::string lastEval;
  std::string lastInfo;
  std::string lastWarn;
  gut::Report::set(
    std::make_shared<TestReport>(
      lastFailure, lastEval, lastInfo, lastWarn));
  gut::Report::set(TestReport(lastFailure, lastEval, lastInfo, lastWarn));
  ...

Implementazione

Report sta giocando tre ruoli distinti: definizione dell'interfaccia, contenitore del report concreto e riferimento centralizzato del reporting. Conviene separare le tre responsabilità: a Concept la definizione dell'interfaccia, a Report il contenimento, a theReport il punto d'accesso:

class Report {
  static std::shared_ptr<Report> report_;
protected:
  Clock clock_;
public:
  static void set(std::shared_ptr<Report> report) {
    report_ = report;
  template<class T>
  Report(T report) : report_ (std::make_shared<Model<T>>(std::move(report))) {
  }
  static void start() {
    if (report_)
      report_->onStart();
  }
  static void end() {
    if (report_)
      report_->onEnd();
  }
  static void startTest(const std::string& name) {
    if (report_)
      report_->onStartTest(name);
  }
  static void endTest() {
    if (report_)
      report_->onEndTest();
  }
  static void failure(const Notice& failure) {
    if (report_)
      report_->onFailure(failure);
    if (FailFast::enabled())
      throw AbortSuite();
  }
  static void eval(const Notice& eval) {
    if (report_)
      report_->onEval(eval);
  }
  static void info(const Notice& info) {
    if (report_)
      report_->onInfo(info);
  }
  static void warn(const Notice& warn) {
    if (report_)
      report_->onWarn(warn);
  }
  static void quit() {
    if (report_)
      report_->onQuit();
  }
  int failedTestCount() { return report_->failedTestCount(); }
protected:
  virtual void onStart() { }
  virtual void onEnd() { }
  virtual void onStartTest(const std::string& /*name*/) { }
  virtual void onEndTest() { }
  virtual void onFailure(const Notice& /*failure*/) { }
  virtual void onEval(const Notice& /*eval*/) { }
  virtual void onInfo(const Notice& /*info*/) { }
  virtual void onWarn(const Notice& /*warn*/) { }
  virtual void onQuit() { }

private:
  struct Concept {
    virtual ~Concept() { }
    virtual void onStart() = 0;
    virtual void onEnd() = 0;
    virtual void onStartTest(const std::string& /*name*/) = 0;
    virtual void onEndTest() = 0;
    virtual void onFailure(const Notice& /*failure*/) = 0;
    virtual void onEval(const Notice& /*eval*/) = 0;
    virtual void onInfo(const Notice& /*info*/) = 0;
    virtual void onWarn(const Notice& /*warn*/) = 0;
    virtual void onQuit() = 0;
    virtual int failedTestCount() = 0;
  };

  template<class T>
  struct Model : public Concept {
    T report_;
    Model(T report) : report_(std::move(report)) { }
    virtual void onStart() {
      report_.onStart();
    }
    virtual void onEnd() {
      report_.onEnd();
    }
    virtual void onStartTest(const std::string& name) {
      report_.onStartTest(name);
    }
    virtual void onEndTest() {
      report_.onEndTest();
    }
    virtual void onFailure(const Notice& failure) {
      report_.onFailure(failure);
    }
    virtual void onEval(const Notice& eval) {
      report_.onEval(eval);
    }
    virtual void onInfo(const Notice& info) {
      report_.onInfo(info);
    }
    virtual void onWarn(const Notice& warn) {
      report_.onWarn(warn);
    }
    virtual void onQuit() {
      report_.onQuit();
    }
    virtual int failedTestCount() {
      return report_.failedTestCount();
    }
  };

  std::shared_ptr<Concept> report_;
};

L'accesso alle funzioni di reporting avviene attraverso una nuova interfaccia statica:

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

class theReport {
  static Report report_;
public:
  static void set(Report report) { report_ = report; }
  static void start() { report_.start(); }
  static void end() { report_.end(); }
  static void startTest(const std::string& name) { report_.startTest(name); }
  static void endTest() { report_.endTest(); }
  static void failure(const Notice& failure) { report_.failure(failure); }
  static void eval(const Notice& eval) { report_.eval(eval); }
  static void info(const Notice& info) { report_.info(info); }
  static void warn(const Notice& warn) { report_.warn(warn); }
  static void quit() { report_.quit(); }
  static int failedTestCount() { return report_.failedTestCount(); }
};

Il report di default non discende più da Report:

class DefaultReport : public Report {
  size_t tests_;
  size_t testFailures_;
  bool testFailed_;
  size_t totalFailures_;
  size_t failedTests_;
  std::vector<Notice> log_;
  Clock clock_;
public:
  size_t int failedTestCount() const {
    return static_cast<int>(failedTests_);
  }
  DefaultReport() : tests_(0), testFailures_(0), totalFailures_(0), failedTests_(0) { }
protected:
  virtual void onStart() {
    tests_ = 0;
    failedTests_ = 0;
    std::cout << "Test suite started..." << std::endl;
  }
  virtual void onEnd() {
    std::cout << "Ran " << tests_ << " test(s) in " << clock_.elapsedTime() << "s." << std::endl;
    if (failedTests_ == 0)
      std::cout << color::lime << "OK - all tests passed." << color::reset << std::endl;
    else
      std::cout << color::red << "FAILED - " << totalFailures_ << " failure(s) in " << failedTests_ << " test(s)." << color::reset << std::endl;
  }
  virtual void onStartTest(const std::string& name) {
    ++tests_;
    testFailures_ = 0;
    testFailed_ = false;
    std::cout << name << ": ";
  }
  virtual void onEndTest() {
    if (testFailures_ == 0) {
    if (!testFailed_) {
      std::cout << "OK" << std::endl;
      flushLog(e_warning);
    }
    else
      testFailed();
    clear();
  }
  virtual void onFailure(const Notice& failure) {
    if (testFailures_ == 0)
    if (!testFailed_)
      std::cout << "FAILED" << std::endl;
    log_.push_back(failure);
    ++testFailures_;
    ++totalFailures_;
    testFailed_ = true;
  }
  virtual void onEval(const Notice& eval) {
    log_.push_back(eval);
  }
  virtual void onInfo(const Notice& info) {
    log_.push_back(info);
  }
  virtual void onWarn(const Notice& warn) {
    log_.push_back(warn);
  }
  virtual void onQuit() {
    testFailed();
  }
  void testFailed() {
    ++failedTests_;
    flushLog(e_info);
  }
  void flushLog(Level minLevel) {
    for (auto notice : log_)
      if (notice.level() >= minLevel)
        std::cout << " " << notice.toString() << std::endl;
  }
  void clear() {
    log_.clear();
  }
};

Report theReport::report_ = Report(DefaultReport());
...

int runTests_() {
  auto report_ = std::make_shared<gut::DefaultReport>();
  gut::Report::set(report_);
  ...
  return report_->failedTestCount();
  return gut::Report::failedTestCount();
}

Una sostituzione globale delle chiamate gut::Report in gut::theReport nei file gut.h e test-gut.cpp conclude l'intervento.

TAP – Test Anything Protocol

Come esempio di report alternativo ho provato ad implementare TAP[1][2]:

// file gut-tap.h
#ifndef GUT_TAP_H
#define GUT_TAP_H

namespace gut {

class TapReport {
  int tests_;
  int failedTests_;
  std::string testName_;
  bool testFailed_;
  std::vector<gut::Notice> log_;
  bool quit_;
public:
  int failedTestCount() const { return failedTests_; }
  void onStart() {
    quit_ = false;
    tests_ = 0;
    failedTests_ = 0;
  }
  void onEnd() {
    if (quit_)
      return;
    std::cout << "1.." << tests_ << std::endl;
    if (tests_ > 0) {
      float okRatio = (tests_ - failedTests_) * 100. / tests_;
      std::cout
        << "# failed " << failedTests_ << "/" << tests_ << " test(s), "
        << std::fixed << std::setprecision(1)
        << okRatio << "% ok" << std::endl;
    }
  }
  void onStartTest(const std::string& name) {
    ++tests_;
    testName_ = name;
    testFailed_ = false;
  }
  void onEndTest() {
    std::ostringstream oss;
    if (testFailed_) {
      ++failedTests_;
      oss << "not ";
    }
    oss << "ok " << tests_ << " - " << testName_;
    for (const auto& entry : log_) {
      oss << "\n# " << entry.toString();
    }
    std::cout << oss.str() << std::endl;
    log_.clear();
  }
  void onFailure(const gut::Notice& failure) {
    log_.push_back(failure);
    testFailed_ = true;
  }
  void onEval(const gut::Notice& eval) {
    log_.push_back(eval);
  }
  void onInfo(const gut::Notice& info) {
    log_.push_back(info);
  }
  void onWarn(const gut::Notice& warn) {
    log_.push_back(warn);
  }
  void onQuit() {
    quit_ = true;
    std::ostringstream oss;
    oss << "Bail out!";
    std::cout << oss.str() << std::endl;
  }
};

} // namespace gut

#endif // GUT_TAP_H

Per attivare il report prescelto si sfrutta il costruttore di un oggetto statico:

// file gut.h
...

#define GUT_ENABLE_REPORT(name_) \
  static struct CustomReport { \
    CustomReport() { gut::theReport::set(name_()); } \
  } customReport_;
...



// file example.cpp
...

GUT_ENABLE_REPORT(gut::TapReport)
...

/* output:
 *
 * ok 1 - Initial list is empty
 * ok 2 - Insertion to empty list is retained
 * ok 3 - Distinct insertions are retained in stack order
 * ok 4 - Duplicate insertions are moved to the front but not inserted
 * ok 5 - Out of range indexing throws exception
 * 1..5
 * # failed 0/5 test(s), 100.0% ok
 */

Per supportare al meglio il protocollo TAP è necessario estendere il framework di test.

Todo

TAP usa la direttiva todo per segnalare test incompleti, per quali è quindi previsto il fallimento. Un todo-test che non fallisce è candidato ad essere promosso al rango di test. Un modo per ottenere questo tipo di segnalazione consiste nel decorare il nome del test con la descrizione che accompagna la direttiva:

// gut-tap.h
...

#define TODO(name_, desc_) \
  TEST(name_ " # todo " desc_)



// example.cpp
...

TODO("Empty strings are ignored", "not yet implemented") {
  RecentlyUsedList aList;
  aList.insert("");

  CHECK(aList.empty());
}

/* output:
 *
 * ...
 * not ok 6 - Empty strings are ignored # todo not yet implemented
 * # example.cpp(85) : [error] aList.empty() evaluates to false
 * 1..6
 * # failed 1/6 test(s), 83.3% ok
 */

Skip

La direttiva skip caratterizza i test che per qualche ragione non sono stati eseguiti; i test ignorati devono essere trattati alla stessa stregua dei test conclusi con successo. A tal scopo è stata definita la macro SKIP. Essa introduce un test vuoto, destinato quindi al successo, inibendo nel contempo il test originale definendo una funzione ausiliaria di comodo, non statica – per evitare l'emissione di messaggi d'errore da parte del linker –, il cui scopo è di “agganciare” il codice di test e renderlo inoperativo:

SKIP(name_, reason_) {                 TEST(name_ " # skipped, reason: " reason_) {
  <test body>                            // empty tesy
}                                      }

                                       void skip_ {
                                         <test body>
                                       }

L'implementazione è la seguente:

// gut-tap.h
...

#define SKIP(name_, reason_) \
  TEST(name_ " # skipped, reason: " reason_) { \
  } \
  void skip ## __LINE__()


// example.cpp
...

SKIP("One trillion insertions are ok", "too slow!") {
  RecentlyUsedList aList;
  for (int i = 0; i < 1000000; ++i)
    for (int j = 0; j < 1000000; ++j)
      for (int k = 0; k < 1000000; ++k)
        aList.insert("one");

  CHECK(aList.size() == 1);
  CHECK(aList[0] == "one");
}

/* output:
 *
 * ...
 * ok 7 - One trillion insertions are ok # skipped, reason: too slow!
 * 1..7
 * # failed 1/7 test(s), 85.7% ok
 */

Bail out

Bail out! è un'indicacazione d'uscita anticipata ed inattesa dal test; l'eccezione AbortSuite può essere utilizzata allo scopo:

// gut-tap.h
...

#define BAIL_OUT \
  do { \
    throw gut::AbortSuite(); \
  } while (0)



// example.cpp
...

TEST("Write to a file") {
  BAIL_OUT;
}

/* output:
 *
 * ...
 * ok 7 - One trillion insertions are ok # skipped, reason: too slow!
 * Bail out!
 */

Per chiarezza, conviene corredare l'evento di una descrizione che ne dettagli la natura:

TEST("Write to a file") {
  BAIL_OUT("File system is read only.");
}

Con pochi interventi di aggiornamento si ottiene quando desiderato:

// gut-tap.h
...

class TapReport {
...
  void onQuit(const std::string& reason) {
    quit_ = true;
    std::ostringstream oss;
    oss << "Bail out!";
    if (!reason.empty ())
      oss << " Reason: " << reason;
    std::cout << oss.str() << std::endl;
  }
...

#define BAIL_OUT(reason_) \
  do { \
    throw gut::AbortSuite(reason_); \
  } while (0)



// gut.h
...

struct class AbortSuite {
  std::string reason_;
public:
  AbortSuite() { }
  AbortSuite(const std::string& reason) : reason_(reason) { }
  const std::string& reason() const { return reason_; }
};
...

class Report {
...
  void quit(const std::string& reason) { report_->onQuit(reason); }
  ...
private:
  struct Concept {
  ...
    virtual void onQuit(const std::string& /*reason*/) = 0;
    ...
  struct Model : public Concept {
  ...
    virtual void onQuit(const std::string& reason) { report_.onQuit(reason); }
    ...
};
...

class theReport {
...
  static void quit(const std::string& report) { report_.quit(report); }
  ...
};
...

class DefaultReport {
...
  void onQuit(const std::string& /*reason*/) {
    testFailed();
  }
  ...
};
...

int runTests_() {
...
    } catch(const gut::AbortSuite& e) {
      gut::theReport::quit(e.reason());
    ...
}

L'esecuzione del test d'esempio genera ora un messaggio più dettagliato:

/* output:
 *
 * ...
 * ok 7 - One trillion insertions are ok # skipped, reason: too slow!
 * Bail out! Reason: File system is read only.
 */

Codice sorgente

Riferimenti

  1. "Test Anything Protocol". wikipedia. <http://en.wikipedia.org/wiki/Test_Anything_Protocol>. Visitato il 04 Febbraio 2014.
  2. "Test::Harness::TAP". metacpan.org. <https://metacpan.org/pod/release/PETDANCE/Test-Harness-2.64/lib/Test/Harness/TAP.pod>. Visitato il 04 Febbraio 2014.

Pagina modificata il 04/02/2014