risorse | good unit tests

Good Unit Tests /5

Questa parte (la quinta; qui la prima, qui la seconda, qui la terza e qui la quarta) affronta alcuni aspetti lasciati in sospeso nella terza parte.

Introduzione

Lo sviluppo della classe di supporto InputTextFile ha messo in evidenza due limiti, per altro già citati, dell'infrastruttura di test gut:

REQUIRE

Quel che segue è un frammento del codice di test dell'oggetto InputTextFile:

// ...

TEST {

  // read file line by line
  {
    InputTextFile file("file1.txt");
    CHECK(file.good());
    CHECK(file.readLine() == "first line");
    CHECK(file.readLine() == "second line");
    // ...
  }
  // ...
}

/* output:
 *
 * test-InputTextFile.cpp(23) : file.good() evaluates to false
 * test-InputTextFile.cpp(18) : unexpected exception "cannot read from file" caught
 * ...
 */

Se per qualche motivo il file non è stato aperto – cfr. direttiva CHECK(file.good()) –, è perfettamente inutile proseguire con il test. In questo caso, la mancata interruzione causa il sollevamento di un'eccezione inattesa da parte della successiva file.readLine(), che viene catturata in TEST. Conviene allora introdurre una nuova direttiva che, oltre a svolgere i controlli implementati da CHECK, richiede in più l'interruzione del test:

#define REQUIRE(expr_) \
  BEGIN_MACRO \
    if (!(gut::Capture()->*expr_)) { \
      gut::Report::failure(gut::RequireFailure(#expr_, gut::Expression::last, __FILE__, __LINE__)); \
      throw gut::AbortTest();\
    } \
  END_MACRO

#define TEST \
  void runTest(); \
  int main() { \
    gut::registerReport(std::make_shared<gut::StdErrReport>()); \
    auto failureCount_ = std::make_shared<gut::FailureCount>(); \
    gut::registerReport(failureCount_); \
    gut::Clock clock_; \
    try { \
      runTest(); \
    } catch(const gut::AbortTest&) { \
    } catch(const std::exception& e) { \
      registerFailure(gut::UnexpectedExceptionFailure(e, __FILE__, __LINE__)); \
    } catch(...) { \
      registerFailure(gut::UnknownExceptionFailure(__FILE__, __LINE__)); \
    } \
    std::cout.precision(3); \
    std::cout << "Test suite ran in " << clock_.elapsedTime() << "s." << std::endl; \
    if (failureCount_->count == 0) \
      std::cout << "All tests passed." << std::endl; \
    else \
      std::cout << failureCount_->count << " test(s) failed." << std::endl; \
    return failureCount_->count; \
  } \
  void runTest()

// ...

namespace gut {
  // ...
struct AbortTest { };

struct Failure {
  std::ostringstream description;
public:
  Location location;
  virtual ~Failure() { }
  Failure(const std::string& level, const char* file, int line) : description(std::ostringstream::out), location(file, line) {
    description << "[" << level << "] ";
  }
  std::string what() const {
    return description.str();
  }
};

struct Error : public Failure {
  Error(const char* file, int line) : Failure("error", file, line) { }
};

struct Fatal : public Failure {
  Fatal(const char* file, int line) : Failure("fatal", file, line) { }
};

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

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

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

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

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

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

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

// ...
} // namespace gut

La richiesta di interruzione consiste nel lancio di un'eccezione AbortTest che viene catturata dal blocco try/catch più esterno in modo “silenzioso”. Con l'occasione si è provveduto a classificare gli oggetti Failure in Error e Fatal: gli oggetti del secondo tipo sono quelli che causano l'interruzione del test. Il codice di test diventa:

// ...

TEST {

  // read file line by line
  {
    InputTextFile file("file1.txt");
    REQUIRE(file.good());
    CHECK(file.readLine() == "first line"); // not executed!
    CHECK(file.readLine() == "second line");
    // ...
  }
  // ...
}

/* output:
 *
 * test-InputTextFile.cpp(23) : [fatal] file.good() evaluates to false
 * ...
 */

operator bool

L'ambiguità su operator<< si presenta quando un oggetto convertibile a bool viene citato in una direttiva CHECK o REQUIRE, in virtù del fatto che i valori booleani sono implicitamente convertibili a intero:

class InputTextFile {
  std::ifstream is_;
  // ...
public:
  operator bool() const {
    return is_.good();
  }
  // ...
};

TEST {
  InputTextFile file("file1.txt");
  CHECK(file); // does not compile!
}
// MinGW 4.8.2 error message

gut.h: In instantiation of 'std::string gut::toString(const T&)…
gut.h:150:41:   required from 'static bool gut::Expression::logAndEvaluate(const T&)…
gut.h:383:41:   required from 'gut::Term<T>::operator bool() const…
test-InputTextFile.cpp:27:1:   required from here
gut.h:87:23: error: ambiguous overload for 'operator<<'…
  os << std::boolalpha << value;
                       ^
gut.h:87:23: note: candidates are:
d:\mingw\include\c++\4.8.2\ostream:166:7: note:…
       operator<<(long __n)
       ^
d:\mingw\include\c++\4.8.2\ostream:170:7: note:…
       operator<<(unsigned long __n)
       ^
d:\mingw\include\c++\4.8.2\ostream:174:7: note:…
       operator<<(bool __n)
       ^
…

Il caso tuttavia è tutt'altro che raro: è infatti probabile che di un oggetto dalla spiccata natura “booleana” si voglia verificare il valore di verità. Una soluzione poco pratica consiste nel forzare la conversione:

TEST {
  InputTextFile file("file1.txt");
  CHECK(static_cast<bool>(file));
}

Meglio piuttosto intervenire in fase di serializzazione:

struct Expression {
  // ...
  template<typename T>
  static bool logAndEvaluate(const T& value) {
    Expression::last = gut::toString(value);
    if (HasOperatorString<T>::value)
      Expression::last = StringRepr<T, HasOperatorString<T>::value>(value).str();
    else
      Expression::last = gut::toString(static_cast<bool>(value));
    return static_cast<bool>(value);
  }
};

HasOperatorString e StringRepr sono due oggetti di comodo: il primo determina se il tipo passato è convertibile a stringa – se dispone cioè di operator std::string() const –, mentre il secondo opera la conversione vera e propria, quando possibile (maggiori dettagli in appendice). Questa modifica fa venire meno la necessità dell'operatore di ridirezione per l'oggetto Boolean, una volta arricchito dell'operatore di conversione a stringa:

struct Boolean {
  bool value_;
  std::string repr_;
  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 {
  operator std::string() const {
    return repr_;
  }
};

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

Il problema dell'ambiguità non è stato risolto alla radice; si ripresenta ogniqualvolta un oggetto convertibile a un tipo primitivo compare all'interno di una direttiva CHECK e REQUIRE:

struct ConvertibleToInt {
  operator int() const {
    return 42;
  }
};

TEST {
  ConvertibleToInt cti;
  CHECK(cti == 7); // does not compile!
}

Questi casi, che dovrebbero a questo punto rappresentare una rarità, sono comunque risolvibili ricorrendo a un cast esplicito:

TEST {
  ConvertibleToInt cti;
  CHECK(static_cast<int>(cti) == 7);
}

/* output:
 *
 * test.cpp(499) : [error] static_cast<int>(cti) == 7 evaluates to 42 == 7
 */

Appendice

Verifica della presenza di un metodo

Nonostante le limitate caratteristiche introspettive del C++, è possibile determinare a runtime se un oggetto implementa un particolare metodo, sfruttando lo SFINAE[4]:

#include <iostream>
#include <string>

template<typename T>
class HasOperatorString {
  typedef char yes[1];
  typedef char no [2];

  template<typename U, U u>
  struct Method {
  };

  template<typename U>
  static yes& check(Method<std::string (U::*)() const, &U::operator std::string>*);

  template<typename U>
  static no& check(...);

public:
  static const bool value = sizeof(yes) == sizeof(check<T>(0));
};

struct Convertible {
  operator std::string() const {
    return "convertible";
  }
};

struct NonConvertible {
  std::string str() const {
    return "non-convertible";
  }
};

int main() {

  std::cout << std::boolalpha;
  std::cout << "int           : " << HasOperatorString<int>::value << std::endl;
  std::cout << "Convertible   : " << HasOperatorString<Convertible>::value << std::endl;
  std::cout << "NonConvertible: " << HasOperatorString<NonConvertible>::value << std::endl;

  return 0;
}

/* output:
 *
 * int           : false
 * Convertible   : true
 * NonConvertible: false
*/

Tutto ha inizio dall'espressione HasOperatorString<Convertible>::value che, effettuando la chiamata check<T>(0), obbliga il compilatore ad individuare il metodo più adatto tra i due disponibili. Essendo la forma con l'ellissi la meno privilegiata, il compilatore verifica dapprima se il tipo T è compatibile con la prima forma:

  static yes& check(Method<std::string (U::*)() const, &U::operator std::string>*);

Affinché la corrispondenza sia soddisfatta, è necessario che il tipo U (che non è altro che un alias di T) possieda un metodo con signature std::string (U::*)() const e nome operator std::string. L'identificazione del metodo istanziato si basa sul valore di ritorno – sulla sua dimensione, per la precisione –. I typedef privati hanno lo scopo di definire due tipi (yes e no) di dimensione differente. Il valore di ritorno è passato per referenza: trattandosi di array, il passaggio per valore è da escludere; il passaggio per puntatore invece, pur possibile, non consentirebbe di discriminare i due casi.

Verifica della disponibilità di un metodo

Come risponde HasOperatorString nel caso di oggetti che non implementano l'operatore di conversione, ma lo ereditano?

// ...

struct Derived : public Convertible {
};

int main() {

  std::cout << std::boolalpha;
  std::cout << "int           : " << HasOperatorString<int>::value << std::endl;
  std::cout << "Convertible   : " << HasOperatorString<Convertible>::value << std::endl;
  std::cout << "NonConvertible: " << HasOperatorString<NonConvertible>::value << std::endl;
  std::cout << "Derived       : " << HasOperatorString<Derived>::value << std::endl;

  return 0;
}

/* output:
 *
 * int           : false
 * Convertible   : true
 * NonConvertible: false
 * Derived       : false
*/

La verifica non ha dato l'esito sperato. Il problema è comunque risolvibile, sempre con la tecnica SFINAE, se si è disposti a rinunciare all'applicabilità del controllo sui tipi primitivi. Se questo è il caso, allora si introduce una prima classe di comodo, che implementa il metodo cercato, ed una seconda, che deriva dalla prima e dal tipo T: se il tipo T implementa il metodo cercato, la classe derivata presenta l'ambiguità da sfruttare per l'istanziazione selettiva del metodo check. La logica ora è opposta al caso precedente: la scelta della versione template di check indica che non c'è l'ambiguità, e dunque il tipo T non implementa il metodo cercato; viceversa, se T dispone del metodo «target», allora l'ambiguità è presente, e il compilatore opta per il metodo check con l'ellissi:

// ...

template<typename T>
class HasOperatorString {
  typedef char yes[1];
  typedef char no [2];

  struct Base {
    operator std::string() const;
  };

  struct Derived : Base, T {
  };

  template<typename U, U u>
  struct Method {
  };

  template<typename U>
  static no& check(Method<std::string (Base::*)() const, &U::operator std::string>*);

  template<typename U>
  static yes& check(...);

public:
  static const bool value = sizeof(yes) == sizeof(check<Derived>(0));
};

// ...

int main() {

  std::cout << std::boolalpha;
  // does not compile!
  // std::cout << "int           : " << HasOperatorString<int>::value << std::endl;
  std::cout << "Convertible   : " << HasOperatorString<Convertible>::value << std::endl;
  std::cout << "NonConvertible: " << HasOperatorString<NonConvertible>::value << std::endl;
  std::cout << "Derived       : " << HasOperatorString<Derived>::value << std::endl;

  return 0;
}

/* output:
 *
 * Convertible   : true
 * NonConvertible: false
 * Derived       : true
 */

Codice sorgente

Classe di supporto InputTextFile e relativi test:

Riferimenti

  1. Karlsson, B. "The Safe Bool Idiom". artima.com. <http://www.artima.com/cppsource/safebool.html>. Visitato il 16 Ottobre 2012.
  2. "More C++ Idioms/Member Detector". wikibooks. <http://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Member_Detector>. Visitato il 16 Ottobre 2012.
  3. "SFINAE to check for inherited member functions". Stack Overflow. <http://stackoverflow.com/questions/1966362/sfinae-to-check-for-inherited-member-functions>. Visitato il 16 Ottobre 2012.
  4. "Substitution failure is not an errors". wikipedia. <http://en.wikipedia.org/wiki/Substitution_failure_is_not_an_error>. Visitato il 16 Ottobre 2012.

Pagina modificata il 15/10/2012