risorse | good unit tests

Good Unit Tests /4

Questa parte (la quarta; qui la prima, qui la seconda e qui la terza) affronta il problema di come circostanziare meglio i fallimenti legati alle espresioni booleane.

Descrizioni estese per le espressioni booleane

A volte può non essere soddisfacente il messaggio che la macro CHECK emette a fronte del fallimento di un'espressione booleana:

#include "gut.h"
#include <cmath>

const double pi = 3.141592653;

bool AreAlmostEqual(double d1,  double d2) {
  return std::fabs(d1 - d2) < 0.001; // just an example!!!
}

TEST {
  CHECK(AreAlmostEqual(pi, 3.14)); // fails
  CHECK(AreAlmostEqual(pi, 3.141)); // succeeds
}

/* output:
 *
 * arealmostequal.cpp(11) : AreAlmostEqual(pi, 3.14) evaluates to false
 * ...
 */

Un modo per ovviare a questo inconveniente, dopo aver opportunamente generalizzato l'implementazione della versione unaria del metodo Expression::logAndEvaluate, è di sostituire la funzione AreAlmostEqual con un oggetto omonimo provvisto di operatore di conversione a bool con la stessa semantica della funzione originale:


struct Expression {
  // ...
  template <typename T>
  static bool logAndEvaluate(boolconst T& value) {
    Expression::last = value ? "true" : "false";
    return value;
    Expression::last = gut::toString(value);
    return static_cast<bool>(value);
  }
  // ...
}

// ...

bool AreAlmostEqual(double d1,  double d2) {
  return std::fabs(d1 - d2) < 0.001;
}

struct AreAlmostEqual {
  double d1_;
  double d2_;
  AreAlmostEqual(double d1, double d2) : d1_(d1), d2_(d2) {
  }
  operator bool() const {
    return std::fabs(d1_ - d2_) < 0.001;
  }
};

La compilazione della nuova versione del test fallisce a causa della presenza di due operator<< compatibili con il nuovo tipo AreAlmostEqual: quello di libreria sul tipo primitivo bool e quello per gli oggetti privi di serializzazione gut::operator<<(std::ostream& os, const NonStreamable&). L'ambiguità si risolve introducendo una versione dedicata dell'operatore di ridirezione:

// ...

std::ostream& operator<<(std::ostream& os, const AreAlmostEqual& aae) {
  os << "|" << aae.d1_ << " - " << aae.d2_ << "| = " << std::fabs(aae.d1_ - aae.d2_) << " >= 0.001";
  return os;
}

TEST {
  CHECK(AreAlmostEqual(pi, 3.14)); // fails
  CHECK(AreAlmostEqual(pi, 3.141)); // succeeds
}

/* output:
 *
 * arealmostequal.cpp(22) : AreAlmostEqual(pi, 3.14) evaluates to |3.14159 - 3.14| = 0.00159265 >= 0.001
 * ...
 */

Nota: la serializzazione descrive la condizione di fallimento dell'operatore booleano: 0.00159265 >= 0.001!

Questa soluzione ha lo svantaggio di richiedere obbligatoriamente la presenza dell'operator<<; una possibile alternativa consiste nel definire una classe base Boolean e il relativo operator<<, e da essa derivare AreAlmostEqual:

// ...

struct Boolean {
  virtual ~Boolean() { }
  operator bool() const {
    return isTrue();
  }
  virtual bool isTrue() const = 0;
  virtual std::string toString() const {
    std::stringstream ss;
    ss << std::boolalpha << isTrue();
    return ss.str();
  }
};

std::ostream& operator<<(std::ostream& os, const Boolean& boolean) {
  return os << boolean.toString();
}

struct AreAlmostEqual : public Boolean {
  double d1_;
  double d2_;
  AreAlmostEqual(double d1, double d2) : d1_(d1), d2_(d2) {
  }
  operator bool() const {
    return std::fabs(d1_ - d2_) < 0.001;
  }
  virtual bool isTrue() const {
    return std::fabs(d1_ - d2_) < 0.001;
  }
  std::string toString() const {
    std::stringstream ss;
    ss << "|" << d1_ << " - " << d2_ << "| >= 0.001";
    return ss.str();
  }
};

std::ostream& operator<<(std::ostream& os, const AreAlmostEqual& aae) {
  os << "|" << aae.d1_ << " - " << aae.d2_ << "| = " << std::fabs(aae.d1_ - aae.d2_) << " >= 0.001";
  return os;
}

Anche questa soluzione non è ottimale: richiede una derivazione, due override… senza dimenticare che se non si fornisce una implementazione dedicata di toString, la struttura AreAlmostEqual ha esattamente lo stesso effetto dell'omonima funzione originaria, con l'aggravante di richiedere la scrittura di molto più codice. Serve necessariamente qualcosa di più compatto, simile a:

// ...

Boolean AreAlmostEqual(double d1, double d2) {
  return Boolean ← (response, expression);
}

La descrizione testuale dell'espressione potrebbe per esempio essere “iniettata” nell'oggetto temporaneo tramite l'operatore di ridirezione:

// ...

Boolean AreAlmostEqual(double d1, double d2) {
  return Boolean(response) << token1 << token2 << ...;
}

Tuttavia, un'implementazione di questo tipo consentirebbe di intervenire sulla descrizione anche in tempi successivi all'istanziazione dell'oggetto Boolean, mentre sarebbe preferibile che la composizione della descrizione sia solo appannaggio di AreAlmostEqual; conviene perciò che la descrizione venga passata nel costruttore, come oggetto stringa:

struct Boolean {
  Boolean(bool value, const std::string& repr) {
    // ...
  }
  // ...
}

Boolean AreAlmostEqual(double d1, double d2) {
  return Boolean(response, TextFlow() << token1 << token2 << ...);
}

ove TextFlow è un oggetto di supporto che si occupa di raccogliere i vari frammenti testuali e riproporli come un unico oggetto std::string. Di seguito la versione completa del test:

#include "gut.h"

const double pi = 3.141592653;

class Boolean {
  bool value_;
  std::string repr_;
public:
  Boolean(bool value) : value_(value), repr_(gut::toString(value)) { }
  Boolean(bool value, const std::string& repr) : value_(value), repr_(repr) { }
  operator bool() const {
    return value_;
  }
  std::string str() const {
    return repr_;
  }
};

std::ostream& operator<<(std::ostream& os, const Boolean& boolean) {
  return os << boolean.str();
}

class TextFlow {
  std::ostringstream oss_;
public:
  template<typename T>
  TextFlow& operator<<(const T& item) {
    oss_ << item;
    return *this;
  }
  operator std::string() const {
    return oss_.str();
  }
};

Boolean AreAlmostEqual(double d1, double d2) {
  double diff = std::fabs(d1 - d2);
  return Boolean(diff < 0.001, TextFlow() << "|" << d1 << " - " << d2 << "| = " << diff << " >= 0.001");
}

TEST {
  CHECK(AreAlmostEqual(pi, 3.14)); // fails
  CHECK(AreAlmostEqual(pi, 3.141)); // succeeds
}

/* output:
 *
 * arealmostequal.cpp(41) : AreAlmostEqual(pi, 3.14) evaluates to |3.14159 - 3.14| = 0.00159265 >= 0.001
 * ...
 */

La classe Boolean permette di fornire una descrizione di un'espressione booleana in maniera quasi immediata, come si può desumere confrontando la versione iniziale della funzione AreAlmostEqual con quella finale:

/* old version
 * bool AreAlmostEqual(double d1,  double d2) {
 *   return std::fabs(d1 - d2) < 0.001;
 * }
 */

// current version
Boolean AreAlmostEqual(double d1, double d2) {
  double diff = std::fabs(d1 - d2);
  return Boolean(diff < 0.001, TextFlow() << "|" << d1 << " - " << d2 << "| = " << diff << " >= 0.001");
}

operator!

Boolean non supporta al meglio la negazione:

// ...

TEST {
  // ...

  double e = 3.141592653;
  CHECK(!AreAlmostEqual(e, pi)); // fails
}

/* output:
 *
 * arealmostequal.cpp(45) : !AreAlmostEqual(e, pi) evaluates to false
 * ...
 */

Come si può notare, la negazione causa la trasformazione dell'oggetto Boolean nel tipo primitivo bool (tramite l'operatore di conversione attivato implicitamente dalla chiamata gut::toString() in Expression::logAndEvaluate(const T&)), che l'infrastruttura di test rappresenta con l'etichetta false. Questo problema non è risolvibile all'interno di Boolean: se è vero che è possibile evitare la conversione Booleanbool dotando Boolean dell'operatore operator!, è altrettanto vero che è praticamente impossibile determinare programmaticamente la forma negata della rappresentazione testuale dell'espressione:

struct Boolean
  // ...
  Boolean operator!() const {
    return Boolean(!value_, ???);
  }
  // ...
};

L'operatore operator! si troverebbe nella condizione di dover «negare» l'espressione "|3.14159 - 3.14| = 0.00159265 < 0.001": la cosa è chiaramente priva di senso. Il problema non è risolvibile nemmeno a livello di Term::operator!, per il semplice motivo che tale operatore non viene mai chiamato: la negazione viene effettuata direttamente sull'argomento della direttiva CHECK, ed è a partire da questa viene istanziato l'oggetto Term.

Il problema deve essere risolto a monte, nel caso specifico introducendo una funzione duale AreQuiteDifferent, oppure in maniera più generale tramite una macro dalla semantica opposta a CHECK, denominata ad esempio CHECK_NOT:

TEST {
  // ...

  double e = 3.141592653;
  CHECK_NOT(AreAlmostEqual(e, pi)); // fails
}

AreTextFileEqual

Un altro caso in cui può tornare utile circostanziare meglio il fallimento di una condizione booleana riguarda il confronto tra file:

// ...

TEST {
  CHECK(AreTextFileEqual("file1.txt", "file2.txt"));
}

/* output:
 *
 * aretextfileequal.cpp(13) : AreTextFileEqual("file1.txt", "file2.txt") evaluates to false
 * ...
 */

Con un minimo di infrastruttura si può ottenere indicazioni precise circa il punto in cui i due file differiscono:

/* output:
 *
 * aretextfileequal.cpp(13) : AreTextFileEqual("file1.txt", "file2.txt") evaluates to false, files are different:
 *  file1.txt(2) : Donec laoreet lectus et nibh scelerisque rhoncus in id lacus.
 *  file2.txt(2) : Morbi imperdiet odio nec felis interdum malesuada.
 * ...
 */

Come si può notare, si ha a disposizione sia il numero di linea ove è stata riscontrata la prima differenza, sia il contenuto della stessa. Se l'informazione viene presentata in un formato simile a quello degli errori emessi dal compilatore (nell'esempio è stato usato quello del VC10 di Microsoft), l'uso di un editor di testo sufficientemente “evoluto” consentirà all'utente di aprire i due file posizionando automaticamente il cursore sulla linea incriminata.

Il codice di supporto alla funzione AreTextFileEqual è riportato qui sotto:

#include <fstream>
#include <stdexcept>

class InputTextFile {
  std::ifstream stream_;
  int lineNumber_;
public:
  class iterator : public std::iterator<std::input_iterator_tag, std::string> {
    InputTextFile* file_;
    std::string line_;
    int lineNumber_;
  public:
    iterator(InputTextFile* file, const std::string& line, int lineNumber)
     : file_(file), line_(line), lineNumber_(lineNumber) {
    }
    iterator& operator=(const iterator& i) {
      file_ = i.file_;
      line_ = i.line_;
      lineNumber_ = i.lineNumber_;
      return *this;
    }
    bool operator==(const iterator& i) const {
      return (file_ == i.file_) && (lineNumber_ == i.lineNumber_);
    }
    bool operator!=(const iterator& i) const {
      return !operator==(i);
    }
    iterator& operator++() {
      return operator=(file_->fetchLine());
    }
    iterator operator++(int) {
      iterator me = *this;
      operator=(file_->fetchLine());
      return me;
    }
    const std::string& operator*() const {
      return line_;
    }
  };
  explicit InputTextFile(const std::string& path)
   : stream_(path.c_str()), lineNumber_(0) {
  }
  operator bool() const {
    return good();
  }
  bool operator!() const {
    return !operator bool();
  }
  iterator begin() {
    std::string line = readLine();
    return line.empty() ? end() : iterator(this, line, lineNumber_);
  }
  iterator end() {
    return iterator(nullptr, "", -1);
  }
  std::string readLine() {
    if (!good())
      throw std::runtime_error("cannot read from file");
    std::string line;
    std::getline(stream_, line);
    ++lineNumber_;
    return line;
  }
private:
  bool good() const {
    return stream_.good();
  }
  iterator fetchLine() {
    if (!good())
      return end();
    std::string line = readLine(); // increments lineNumber_
    return iterator(this, line, lineNumber_);
  }
};

// tuples not available in VC10
template<class T, class U>
struct first_diff {
  T iter1;
  U iter2;
  size_t count;
  first_diff(const T& i1, const U& i2, size_t c) : iter1(i1), iter2(i2), count(c) { }
};

template<class T, class U>
first_diff<T, U> mismatch(T first1, T last1, U first2, U last2) {
  size_t count = 0;
  while ((first1 != last1) && (first2 != last2) && (*first1==*first2)) {
    ++count;
    ++first1;
    ++first2;
  }
  return first_diff<T, U>(first1, first2, count);
}

Boolean AreTextFileEqual(const std::string& path1, const std::string& path2) {
  static const std::string fileIsExausted("<past-the-end-of-file>");

  InputTextFile f1(path1);

  if (!f1)
    return Boolean(
      false,
      TextFlow() << "false, \"" << path1 << "\" not found");

  InputTextFile f2(path2);

  if (!f2)
    return Boolean(
      false,
      TextFlow() << "false, \"" << path2 << "\" not found");

  const auto diff = mismatch(f1.begin(), f1.end(), f2.begin(), f2.end());
  const bool isFile1Exausted = (diff.iter1 == f1.end());
  const bool isFile2Exausted = (diff.iter2 == f2.end());

  if (isFile1Exausted && isFile2Exausted)
    return true;
  else
    return Boolean(
      false,
      TextFlow()
        << "false, files are different:\n"
        << " " << path1 << "(" << diff.count + 1 << ") : "
        << (isFile1Exausted ? fileIsExausted : *diff.iter1)
        << "\n"
        << " " << path2 << "(" << diff.count + 1 << ") : "
        << (isFile2Exausted ? fileIsExausted : *diff.iter2));
}

In questo caso, l'assenza dell'operatore di negazione in Boolean è tutto sommato accettabile – come a dire: Boolean::operator! non sempre serve. Nel caso infatti una direttiva CHECK(!AreTextFileEqual("file1.txt", "file2.txt")) fallisca, la segnalazione:

  aretextfileequal.cpp(13) : !AreTextFileEqual("file1.txt", "file2.txt") evaluates to false

è perfettamente accettabile, poiché, essendo i file identici, non c'è ragione di dettagliare ulteriormente la situazione.

Codice sorgente

Classe di supporto InputTextFile e relativi test:

Pagina modificata il 12/10/2012