risorse | good unit tests

Good Unit Tests /1

Introduzione

Kevlin Henney ha presentato un interessante framework di unit-testing C++ alla conferenza ACCU di Londra del 2010[1]. L'idea alla base è semplice quanto “rivoluzionaria”: C++ non è Java, e dunque perché cercare di emulare il funzionamento di jUnit? Perché non tentare una strada alternativa, più adatta alle caratteristiche del linguaggio? Di più, perché non sfruttare l'occasione per introdurre una concezione diversa dello unit-test?

Lo unit-testing, nella sua incarnazione più classica, considera corretto il funzionamento di un oggetto sulla base del funzionamento dei suoi metodi; un esempio di unit-test monolitico di un'ipotetico oggetto Stack è il seguente:

int main() {
  Stack aStack;
  assert(aStack.empty());
  aStack.push(1);
  assert(!aStack.empty());
  aStack.push(2);
  assert(aStack.pop() == 2);
  assert(!aStack.empty());
  assert(aStack.pop() == 1);
  assert(aStack.empty());
  try {
    aStack.pop();
    assert(false);
  } catch (...
  // ...

Se l'esecuzione dello unit-test non solleva alcun errore, l'oggetto Stack è considerato essere funzionanate. Di norma i test dei singoli metodi sono effettuati in funzioni dedicate; in questo caso lo unit-test si eleva al rango di procedurale:

void testEmpty() {
  Stack aStack;
  assert(aStack.empty());
  aStack.push(1);
  assert(!aStack.empty());
}

// ...

int main() {
  testEmpty();
  testPush();
  testPop();
  // ...

I limiti di questi unit-test “vecchia maniera” (detti anche POUT, per plain old unit testing) stanno nel fatto che il test di un singolo metodo non dice nulla circa l'uso tipico che si fa dell'oggetto in esame; in secondo luogo, raramente il test di un metodo può essere effettuato in perfetto isolamento: testEmpty ad esempio chiama Stack::push, ma anche la sola istanziazione dell'oggetto aStack causa una chiamata implicita al costruttore.

Kevlin sostiene che è possibile trasformare uno unit-test in una specifica d'uso dell'oggetto, conseguendo il duplice obiettivo di verificare il funzionamento dell'oggetto, esplicitando al contempo l'uso che se ne dovrebbe fare. Secondo questa nuova ottica, uno unit-test diventa una specifica costituita da proposizioni che asseriscono certe proprietà dell'oggetto. Questa forma di unit-testing prende il nome di verifica comportamentale (behavioural test); il nome del test è ispirato ad un requisito, mentre lo stile si rifà al concetto di use-case o user-story:

SPECIFICATION("Stack")
{
  PROPOSITION("stack is initially empty")
  {
     Stack aStack;
     IS_TRUE(aStack.empty());
  }

  PROPOSITION("objects are retrieved in reverse order")
  {
     Stack aStack;
     aStack.push(1);
     aStack.push(2);
     int a = aStack.top();
     aStack.pop();
     int b = aStack.top();

     IS_TRUE(a == 2);
     IS_TRUE(b == 1);
  }

  // ...
}

La prima parte della proposizione evidenzia l'uso che si deve fare dell'oggetto per attivare la funzionalità desiderata – richiamando tutti i metodi necessari, allontanandosi quindi definitivamente dallo unit-testing basato sul singolo metodo –, la seconda parte ne verifica l'effetto.

Implementazione

Ad oggi Kevlin non ha ancora reso disponibile il codice sorgente, giudicandolo in uno stato ancora prototipale. Ho tentato perciò di sperimentare autonomamente alcune delle tecniche da lui discusse. Per alcune delle implementazioni ho tratto ispirazione dalla libreria Catch[2] di Phil Nash, che è liberamente scaricabile da github.

Questa prima parte è dedicata alla cattura dei valori assunti dai termini dell'espressione che ha causato il fallimento del test.

Valutazione dei termini dell'espressione di test

Tutti i framework di unit-test dispongono di una macro per verificare se un'espressione booleana è soddisfatta o meno, causando il fallimento del test nel secondo caso:

CHECK(a == b); // test fails if a != b

L'eventuale fallimento del test viene di norma segnalato da un messaggio d'errore corredato dal nome del file e dal numero della riga contenente la condizione non soddisfatta, oltre all'espressione stessa; un risultato del genere può essere ottenuto dalla seguente definizione:

#include <iostream>

#define CHECK(expr_) \
  do { \
    if (!(expr_)) \
      std::cout << __FILE__ << "(" << __LINE__ << "): " << #expr_ << " failed" << std::endl; \
  } while (0)

int main() {
  int i1 = 1;
  int i2 = 2;
  CHECK(i1 == i2);
  return 0;
}

// output:
// ...\check.cpp(12): i1 == i2 failed

Il messaggio d'errore prodotto dalla macro CHECK riporta l'espressione originale i1 == i2, ma non i valori assunti dalle due variabili in causa, che risulterebbero sicuramente di maggior interesse. Alcuni framework risolvono il problema predisponendo delle macro apposite, come nell'esempio sottostante:

#include <iostream>

#define CHECK_EQUAL(expr1_, expr2_) \
  do { \
    if (!(expr1_ == expr2_)) \
      std::cout << __FILE__ << "(" << __LINE__ << "): " \
                << expr1_ << " != " << expr2_ << std::endl; \
  } while (0)

int main() {
  int i1 = 1;
  int i2 = 2;
  CHECK_EQUAL(i1, i2);
  return 0;
}

// output:
// ...\check.cpp(13): 1 != 2

Ciò tuttavia richiede la definizione di una macro per ogni operatore relazionale:

  CHECK_EQUAL(i1, i2);                 // CHECK(i1 == i2);
  CHECK_NOT_EQUAL(i1, i2);             // CHECK(i1 != i2);
  CHECK_LESS_THAN_OR_EQUAL_TO(i1, i2); // CHECK(i1 <= i2);
  // ...

Kevlin, nel suo intervento, riferisce di come sia possibile «catturare» i valori dei termini di un'espressione binaria per mezzo dell'operatore puntatore-a-membro. L'idea è di introdurre artificialmente una chiamata di questo tipo immediatamente prima dell'espressione binaria:

class Capture {
  // ...
};

// ...
Capture capture;
capture->*i1 == i2;

La maggiore priorità dell'operatore puntatore-a-membro rispetto all'operatore di uguaglianza fa sì che l'espressione valutata sia:

  (capture.operator->*(i1)) == i2;

per cui, se l'operatore puntatore-a-membro di Capture restituisce un oggetto di tipo Term, si ha:

template<typename T>
class Term {
public:
  Term(const T& lhs) {
    // ...
  }
};

class Capture {
  // ...
public:
  template<typename T>
  Term<T> operator->*(const T& term) {
    return Term<T>(term);
  }
  // ...
};

// ...
Capture capture;
capture->*i1 == i2; // Term<int>(i1) == i2;

Continuando nella valutazione dell'espressione, ora viene chiamato in causa l'operatore di uguaglianza dell'oggetto di classe Term sul parametro i2:

  Term<int>(i1).operator==(i2);

L'espressione può dunque essere valutata in Term::operator==:

template<typename T>
class Term {
public:
  Term(const T& lhs) {
    // ...
  }
  template<typename U>
  bool operator==(const U& rhs) const {
    // ...
  }
  // ...
};

// ...

Se nel costruttore si ha l'accortezza di tener traccia del parametro lhs, l'operatore di uguaglianza ha accesso ad entrambi i termini dell'espressione binaria iniziale:

#include <iostream>
#include <sstream>
#include <string>

std::string last_expr;

template<typename T>
class Term {
  const T& lhs_;
public:
  Term(const T& lhs) : lhs_(lhs) { }
  template<typename U>
  bool operator==(const U& rhs) const {
    std::stringstream ss;
    ss << lhs_ << " == " << rhs;
    last_expr = ss.str();
    return lhs_ == rhs;
  }
};

class Capture {
public:
  template<typename T>
  Term<T> operator->*(const T& term) {
    return Term<T>(term);
  }
};

#define CHECK(expr_) \
  do { \
    if (!(Capture()->*expr_)) \
      std::cout << __FILE__ << "(" << __LINE__ << "): " << #expr_ << " evaluates to " << last_expr << std::endl; \
  } while (0)

int main() {
  int i1 = 1;
  int i2 = 2;
  CHECK(i1 == i2);
  return 0;
}

// output:
// ...\check.cpp(38): i1 == i2 evaluates to 1 == 2

Ricapitolando:

  1. la macro CHECK istanzia un temporaneo di tipo Capture al quale passa l'espressione binaria, e si preoccupa gestire il fallimento nel caso il valore di ritorno sia false;
  2. l'oggetto Capture istanzia un oggetto di tipo Term<T> dove T rappresenta il tipo del valore i1;
  3. l'oggetto Term appena istanziato riceve il termine di destra dell'espressione in operator==, dove U rappresenta il tipo del valore i2;
  4. in operator==, l'oggetto Term<T> ha l'occasione per registrare il valore dei termini in gioco, prima di procedere alla valutazione dell'espressione di uguaglianza.

Estensioni

Espressioni unarie

Un framework di test che si possa definire tale deve consentire anche la verifica di espressioni del tipo:

  CHECK(b);      // b is bool
  CHECK(!o);     // o is an object convertible to bool
  CHECK(f(...)); // f returns a bool
  // ...

A tal fine, è necessario che l'espressione Capture()->*expr_ ritorni un booleano; ricordando le cosiderazioni fatte in precedenza, essa equivale a Term<int>(expr_), ed è perciò sufficiente arricchire la classe Term dell'operatore di conversione a bool:

template<typename T>
class Term {
  //...
  operator bool() const {
    last_expr = lhs_ ? "true" : "false";
    return lhs_;
  }
}
// ...

bool f() {
  return false;
}

int main() {
  // ...
  CHECK(f());
  return 0;
}
// output:
// ...\check.cpp(48): f() evaluates to false

Altri operatori relazionali

L'uso degli altri operatori relazionali all'interno della macro CHECK è condizionato dalla presenza del relativo metodo Term::operator; ad esempio, per l'operatore di disuguaglianza:

template<typename T>
class Term {
  //...
  template<typename U>
  bool operator!=(const U& rhs) const {
    std::stringstream ss;
    ss << lhs_ << " != " << rhs;
    last_expr = ss.str();
    return lhs_ != rhs;
  }
}
// ...

int main() {
  int i1 = 1;
  int i2 = 2;
  CHECK(i1 == i2);
  CHECK(i1 != 1);
  CHECK(f());
  return 0;
}

// output:
// ../check.cpp(59): i1 == i2 evaluates to 1 == 2
// ../check.cpp(60): i1 != 1 evaluates to 1 != 1

L'attivazione degli altri operatori relazionali avviene analogamente.

Vale ora la pena di rifattorizzare in una classe apposita la registrazione dell'espansione dell'espressione, lasciando alla classe Term il solo compito di catturare il valore dei due operandi:

class Term {
  //...
  template<typename U>
  bool operator==(const U& rhs) const {
    return Expression::logAndEvaluate(Equal<T, U>(lhs_, rhs));
  }
  template<typename U>
  bool operator!=(const U& rhs) const {
    return Expression::logAndEvaluate(NotEqual<T, U>(lhs_, rhs));
  }
  operator bool() const {
    return Expression::logAndEvaluate(lhs_);
  }
  // ...

Segue la definizione della classe ausiliaria Expression:

struct Expression {
  virtual bool evaluate() const = 0;
  virtual std::string toString() const = 0;

  static std::string last;

  // unary expressions support
  static bool logAndEvaluate(bool value) {
    Expression::last = value ? "true" : "false";
    return value;
  }
  // n-ary expressions support
  static bool logAndEvaluate(const Expression& expression) {
    Expression::last = expression.toString();
    return expression.evaluate();
  }
};

std::string Expression::last;

Expression costituisce la classe base delle espressioni relezionali che si intendono valutare, le cui derivate, a fronte dei termini in gioco, devono provvedere a salvare l'espansione in Expression::last e ritornare il valore booleano corretto. Di seguito le strutture associate agli operatori di uguaglianza e disugaglianza:

template<typename T, typename U>
struct BinaryExpression : public Expression {
  const T& lhs_;
  const U& rhs_;
  BinaryExpression(const T& lhs, const U& rhs) : lhs_(lhs), rhs_(rhs) { }
  virtual std::string toString() const {
    std::stringstream ss;
    ss << lhs_ << " " << getOpName() << " " << rhs_;
    return ss.str();
  }
  virtual std::string getOpName() const = 0;
};

template<typename T, typename U>
struct Equal : public BinaryExpression<T, U> {
  Equal(const T& lhs, const U& rhs) : BinaryExpression<T, U>(lhs, rhs) { }
  virtual bool evaluate() const { return this->lhs_ == this->rhs_; }
  virtual std::string getOpName() const { return "=="; }
};

template<typename T, typename U>
struct NotEqual : public BinaryExpression<T, U> {
  NotEqual(const T& lhs, const U& rhs) : BinaryExpression<T, U>(lhs, rhs) { }
  virtual bool evaluate() const { return this->lhs_ != this->rhs_; }
  virtual std::string getOpName() const { return "!="; }
};

Va infine modificata la macro CHECK, che trova ora l'espansione dell'espressione in Expression::last:

#define CHECK(expr_) \
  do { \
    if (!(Capture()->*expr_)) \
      std::cout << __FILE__ << "(" << __LINE__ << "): " << #expr_ << " evaluates to " << Expression::last << std::endl; \
  } while (0)

Tipi di dato

Stringhe

Sebbene finora si siano usati – a titolo d'esempio – esclusivamente dati numerici e booleani, tutti i tipi di dato serializzabili in un oggetto std::stringstream sono supportati, in particolare le stringhe e gli array di caratteri, come dimostra il seguente frammento di codice:

// ...
int main() {
  // ...
  std::string s1 = "1";
  std::string s2 = "2";
  CHECK(s1 == s2);
  CHECK(s1 == "2");
  CHECK("1" == s2);
  CHECK("1" == "2");
  return 0;
}

// output:
// ...\check.cpp(59): s1 == s2 evaluates to 1 == 2
// ...\check.cpp(60): s1 == "2" evaluates to 1 == 2
// ...\check.cpp(61): "1" == s2 evaluates to 1 == 2
// ...\check.cpp(62): "1" == "2" evaluates to 1 == 2

L'assenza dell'indicazione del tipo di dato può generare situazioni ambigue: nel primo caso, ad esempio, il programmatore potrebbe essere indotto a pensare che le variabili s1 ed s2 siano di tipo intero. Conviene allora esplicitare il tipo delle variabili in gioco in qualche forma, per esempio:

// output:
// ...\check.cpp(59): s1 == s2 evaluates to {1} == {2}
// ...\check.cpp(60): s1 == "2" evaluates to {1} == "2"
// ...\check.cpp(61): "1" == s2 evaluates to "1" == {2}
// ...\check.cpp(62): "1" == "2" evaluates to "1" == "2"

dove si è usata la convenzione di racchiudere gli array di caratteri tra doppi apici e gli oggetti stringa tra parentesi graffe.

Si introduce allora un ulteriore livello di indirezione nella conversione dei valori assunti dai termini in stringa, modificando il metodo BinaryExpression::toString come di seguito indicato:

template<typename T>
std::string toString(const T& value) {
  std::ostringstream os;
  os << value;
  return os.str();
}

// ...

template<typename T, typename U>
struct BinaryExpression : public Expression {
  // ...
  virtual std::string toString() const {
    return ::toString(lhs_) + " " + getOpName() + " " + ::toString(rhs_);
  }

A questo punto si può procedere alla specializzazione della funzione globale toString per gli array di caratteri e le stringhe:

std::string toString(const char* value) {
  std::ostringstream os;
  os << "\"" << value << "\"";
  return os.str();
}

std::string toString(const std::string& value) {
  std::ostringstream stream;
  stream << "{" << value << "}";
  return stream.str();
}

Booleani

Una volta introdotta la funzione toString, è banale specializzarla per i valori booleani:

std::string toString(bool value) {
  std::ostringstream stream;
  stream << std::boolalpha << value;
  return stream.str();
}

int main() {
  // ...
  bool b1 = true;
  bool b2 = false;
  CHECK(b1 == b2);
  return 0;
}

// output:
// ...\check.cpp(132): b1 == b2 evaluates to true == false

Senza la specializzazione, la risposta del programma sarebbe stata:

// ...\check.cpp(132): b1 == b2 evaluates to 1 == 0

Interi

A volte è utile disporre del tipo signed/unsigned di un valore intero, o la sua dimensione effettiva; altre volte è conveniente rappresentarlo in una base specifica. Il codice sottostante consente di definire la base di rappresentazione dei valori interi (macro INT_BASE) e dei caratteri (CHAR_BASE), avendo come opzioni quella decimale (Dex) o esadecimale (Hex):

#include <iomanip>

// ...

#define INT_BASE Dec
#define CHAR_BASE Hex

#define CONCAT(a, b) a ## b
#define CONCAT_(a, b) CONCAT(a, b)
#define INT_TO_STRING CONCAT_(as, INT_BASE)
#define CHAR_TO_STRING CONCAT_(as, CHAR_BASE)

// ...

std::string asDec(long long value, const char* suffix) {
  std::ostringstream os;
  os << value << suffix;
  return os.str();
}

template<typename T>
std::string asDec(const T& value) {
  return asDec(value, "");
}

std::string asDec(unsigned value) {
  return asDec(value, "u");
}

std::string asDec(long value) {
  return asDec(value, "l");
}

std::string asDec(unsigned long value) {
  return asDec(value, "ul");
}

std::string asDec(long long value) {
  return asDec(value, "ll");
}

std::string asDec(unsigned long long value) {
  return asDec(value, "ull");
}

std::string asHex(long long value, size_t width) {
  std::ostringstream os;
  os << "0x" << std::hex << std::setw(width) << std::setfill('0') << value;
  return os.str();
}

template<typename T>
std::string asHex(const T& value) {
  return asHex(value, sizeof(T) * 2);
}

std::string asHex(char value) {
  return asHex(static_cast<int>(value), 2);
}

template<typename T>
std::string intToString(const T& value) {
  return INT_TO_STRING(value);
}

std::string toString(short value) {
  return intToString(value);
}

std::string toString(int value) {
  return intToString(value);
}

std::string toString(long value) {
  return intToString(value);
}

std::string toString(long long value) {
  return intToString(value);
}

std::string toString(unsigned short value) {
  return intToString(value);
}

std::string toString(unsigned int value) {
  return intToString(value);
}

std::string toString(unsigned long value) {
  return intToString(value);
}

std::string toString(unsigned long long value) {
  return intToString(value);
}

std::string toString(char value) {
  std::ostringstream os;
  os << "'" << value << "' " << CHAR_TO_STRING(value);
  return os.str();
}

std::string toString(unsigned char value) {
  std::ostringstream os;
  os << CHAR_TO_STRING(value);
  return os.str();
}

// ...

int main() {
  // ...
  unsigned short us = 7;
  unsigned long ul = 8;
  CHECK(us == ul);
  long long ll1 = 23612343;
  long long ll2 = 876543445676;
  CHECK(ll1 == ll2);

  char c1 = 'o';
  unsigned char uc2 = 0x05;
  CHECK(c1 == uc2);
  return 0;
}

// output:
// ...\check.cpp(220): us == ul evaluates to 7 == 8ul
// ...\check.cpp(223): ll1 == ll2 evaluates to 23612343ll == 876543445676ll
// ...\check.cpp(227): c1 == uc2 evaluates to 'o' 0x6f == 0x05

Senza la specializzazione, la risposta del programma sarebbe stata:

// ...\check.cpp(139): us == ul evaluates to 7 == 8
// ...\check.cpp(142): ll1 == ll2 evaluates to 23612343 == 876543445676
// ...\check.cpp(146): c1 == uc2 evaluates to o == ?

Puntatori

Il confronto da puntatori è in parte già supportato, come dimostra il seguente frammento di codice (i valori riportati in output sono puramente indicativi!):

// ...

int main() {
  // ...
  int* pi1 = &i1;
  int* pi2 = &i2;
  CHECK(pi1 == pi2);
  CHECK(pi1 == &i2);
  CHECK(&i1 == pi2);
  CHECK(&i1 == &i2);
  const int* cpi1 = &i1;
  const int* cpi2 = &i2;
  CHECK(cpi1 == cpi2);
  CHECK(cpi1 == &i2);
  CHECK(&i1 == cpi2);
  CHECK(&i1 == &i2);
  CHECK(cpi1 == pi2);
  CHECK(pi1 == cpi2);
  return 0;
}

// output:
// ...\check.cpp(231): pi1 == pi2 evaluates to 0012FF50 == 0012FF44
// ...\check.cpp(232): pi1 == &i2 evaluates to 0012FF50 == 0012FF44
// ...\check.cpp(233): &i1 == pi2 evaluates to 0012FF50 == 0012FF44
// ...\check.cpp(234): &i1 == &i2 evaluates to 0012FF50 == 0012FF44
// ...\check.cpp(237): cpi1 == cpi2 evaluates to 0012FF50 == 0012FF44
// ...\check.cpp(238): cpi1 == &i2 evaluates to 0012FF50 == 0012FF44
// ...\check.cpp(239): &i1 == cpi2 evaluates to 0012FF50 == 0012FF44
// ...\check.cpp(240): &i1 == &i2 evaluates to 0012FF50 == 0012FF44
// ...\check.cpp(241): cpi1 == pi2 evaluates to 0012FF50 == 0012FF44
// ...\check.cpp(242): pi1 == cpi2 evaluates to 0012FF50 == 0012FF44

Quel che non è supportato è il confronto tra un puntatore e una delle costanti NULL/0:

// ...

int main() {
  // ...
  // does not compile!
  CHECK(pi1 == 0);
  CHECK(pi1 == NULL);
  CHECK(&i1 == 0);
  CHECK(&i1 == NULL);

  int* pnull = NULL;
  CHECK(pnull != NULL);
  CHECK(pnull != 0);

  const int* cpnull = NULL;
  CHECK(pnull != cpnull);
  CHECK(cpnull != pnull);
  CHECK(cpi1 == 0);
  CHECK(cpi1 == NULL);

Questo tipo di confronti sono gestiti da una specializzazione di Equal/NotEqual per i tipi <T*, int>:

// ...

template<typename T, typename U>
struct Equal_ : public BinaryExpression<T, U> {
  Equal_(const T& lhs, const U& rhs) : BinaryExpression<T, U>(lhs, rhs) { }
  virtual std::string getOpName() const { return "=="; }
};

template<typename T, typename U>
struct Equal : public Equal_<T, U> {
  Equal(const T& lhs, const U& rhs) : Equal_<T, U>(lhs, rhs) { }
  virtual bool evaluate() const { return this->lhs_ == this->rhs_; }
};

template<typename T>
struct Equal<T*, int> : public Equal_<T*, int> {
  Equal(T* const& lhs, int rhs) : Equal_<T*, int>(lhs, rhs) { }
  virtual bool evaluate() const { return this->lhs_ == reinterpret_cast<T*>(this->rhs_); }
};

template<typename T, typename U>
struct NotEqual_ : public BinaryExpression<T, U> {
  NotEqual_(const T& lhs, const U& rhs) : BinaryExpression<T, U>(lhs, rhs) { }
  virtual std::string getOpName() const { return "!="; }
};

template<typename T, typename U>
struct NotEqual : public NotEqual_<T, U> {
  NotEqual(const T& lhs, const U& rhs) : NotEqual_<T, U>(lhs, rhs) { }
  virtual bool evaluate() const { return this->lhs_ != this->rhs_; }
};

template<typename T>
struct NotEqual<T*, int> : public NotEqual_<T*, int> {
  NotEqual(T* const& lhs, int rhs) : NotEqual_<T*, int>(lhs, rhs) { }
  virtual bool evaluate() const { return this->lhs_ != reinterpret_cast<T*>(this->rhs_); }
};

// ...

int main() {
  // ...
  CHECK(pi1 == 0);
  CHECK(pi1 == NULL);
  CHECK(&i1 == 0);
  CHECK(&i1 == NULL);

  int* pnull = NULL;
  CHECK(pnull != NULL);
  CHECK(pnull != 0);

  const int* cpnull = NULL;
  CHECK(pnull != cpnull);
  CHECK(cpnull != pnull);
  CHECK(cpi1 == 0);
  CHECK(cpi1 == NULL);
  return 0;
}

// output:
// ...\check.cpp(279): pi1 == 0 evaluates to 0012FF50 == 0
// ...\check.cpp(280): pi1 == NULL evaluates to 0012FF50 == 0
// ...\check.cpp(281): &i1 == 0 evaluates to 0012FF50 == 0
// ...\check.cpp(282): &i1 == NULL evaluates to 0012FF50 == 0
// ...\check.cpp(285): pnull != NULL evaluates to 00000000 != 0
// ...\check.cpp(286): pnull != 0 evaluates to 00000000 != 0
// ...\check.cpp(289): pnull != cpnull evaluates to 00000000 != 00000000
// ...\check.cpp(290): cpnull != pnull evaluates to 00000000 != 00000000
// ...\check.cpp(291): cpi1 == 0 evaluates to 0012FF50 == 0
// ...\check.cpp(292): cpi1 == NULL evaluates to 0012FF50 == 0

Il metodo getOpName è stato fattorizzato in Equal_; alla struttura Equal<T, U> ora è affiancata Equal<T*, int>, dedicata al confronto puntatore/costante.

Il confronto costante/puntatore è garantito dalle seguenti definizioni simmetriche in <int, T*>:

// ...

template<typename T>
struct Equal<int, T*> : public Equal_<int, T*> {
  Equal(int lhs, T* const& rhs) : Equal_<int, T*>(lhs, rhs) { }
  virtual bool evaluate() const { return reinterpret_cast<T*>(this->lhs_) == this->rhs_; }
};

template<typename T>
struct NotEqual<int, T*> : public NotEqual_<int, T*> {
  NotEqual(int lhs, T* const& rhs) : NotEqual_<int, T*>(lhs, rhs) { }
  virtual bool evaluate() const { return reinterpret_cast<T*>(this->lhs_) != this->rhs_; }
};

// ...

int main() {
  // ...
  CHECK(0 == pi1);
  CHECK(NULL == &i1);
  CHECK(0 != pnull);
  CHECK(NULL != pnull);
  CHECK(0 == cpi1);
  CHECK(NULL == cpi1);
}

// output:
// ...\check.cpp(294): 0 == pi1 evaluates to 0 == 0012FF50
// ...\check.cpp(295): NULL == &i1 evaluates to 0 == 0012FF50
// ...\check.cpp(296): 0 != pnull evaluates to 0 != 00000000
// ...\check.cpp(297): NULL != pnull evaluates to 0 != 00000000
// ...\check.cpp(298): 0 == cpi1 evaluates to 0 == 0012FF50
// ...\check.cpp(299): NULL == cpi1 evaluates to 0 == 0012FF50

Oggetti

Anche le istanze di classi definite dall'utente possono essere oggetto della macro CHECK, purché corredate dall'operatore operator<<, oltre ovviamente all'operatore utilizzato nella macro:

// ...
class Object {
  int m_id;
public:
  Object(int id) : m_id(id) { }
  Object(const Object& co) : m_id(co.m_id) { }
  Object& operator=(const Object& co) {
    m_id = co.m_id;
    return *this;
  }
  bool operator==(const Object& co) const { return m_id == co.m_id; }
  int GetId() const { return m_id; }
};

std::ostream& operator<<(std::ostream& os, const Object& co) {
  return os << "Object#" << co.GetId(); }

// ...

int main() {
  // ...
  Object o1(1);
  Object o2(2);
  CHECK(o1 == o2);
  CHECK(o1 == Object(2));
  CHECK(Object(1) == o2);
  CHECK(Object(1) == Object(2));
  return 0;
}

// output:
// ...\check.cpp(312): o1 == o2 evaluates to Object#1 == Object#2
// ...\check.cpp(313): o1 == Object(2) evaluates to Object#1 == Object#2
// ...\check.cpp(314): Object(1) == o2 evaluates to Object#1 == Object#2
// ...\check.cpp(315): Object(1) == Object(2) evaluates to Object#1 == Object#2

È possibile rilassare il vincolo riguardante l'operatore di ridirezione introducendo una forma di serializzazione di default per tutti quegli oggetti che ne sono privi:

// ...

struct NonStreamableTerm {
  template<typename T>
  NonStreamableTerm(const T&) { }
};

std::ostream& operator<<(std::ostream& os, const NonStreamableTerm&) {
  return os << "{?}";
}

// ...

class NonSerializableObject {
  int m_id;
public:
  NonSerializableObject(int id) : m_id(id) { }
  bool operator==(const NonSerializableObject& co) const { return m_id == co.m_id; }
};

// ...

int main() {
  // ...
  NonSerializableObject nso1(1);
  NonSerializableObject nso2(2);
  CHECK(nso1 == nso2);
  CHECK(nso1 == NonSerializableObject(2));
  CHECK(NonSerializableObject(1) == nso2);
  CHECK(NonSerializableObject(1) == NonSerializableObject(2));
  return 0;
}

// output:
// ...\check.cpp(356): nso1 == nso2 evaluates to {?} == {?}
// ...\check.cpp(357): nso1 == NonSerializableObject(2) evaluates to {?} == {?}
// ...\check.cpp(358): NonSerializableObject(1) == nso2 evaluates to {?} == {?}
// ...\check.cpp(359): NonSerializableObject(1) == NonSerializableObject(2) evaluates to {?} == {?}

Segnalazione dell'errato uso dell'operatore di assegnamento

Sfruttando l'overloading degli operatori, è facile produrre un errore di compilazione «custom» nel caso in cui l'argomento della macro CHECK sia un'assegnamento anziché un'uguaglianza:

int main() {
  // ...
  CHECK(i1 = i2); // should have been i1 == i2

Il compilatore produce in questo caso un errore piuttosto difficile da decifrare circa l'impossibilità di determinare il corretto Term<T>::operator= da chiamare; è possibile tuttavia intervenire sul messaggio d'errore introducendo una struttura dal nome evocativo, sperando che il compilatore ne riporti il nome all'interno del messaggio d'errore:

// ...
struct UNEXPECTED_ASSIGNMENT;

template<typename T>
class Term {
  // ...
public:
  template<typename U>
  UNEXPECTED_ASSIGNMENT operator=(const U& value) const;
};

Limiti

Espressioni «complesse»

L'infrastruttura non consente il test di espressioni arbitrariamente complesse:

int main() {
  // ...
  int i1 = 1;
  int i2 = 2;
  CHECK(i1 + 1 == 1);
  return 0;
}

// output:
// ...\check.cpp(299): i1 + 1 == 1 evaluates to true

Il test precedente fallisce – correttamente –, ma l'espansione prodotta per l'espressione è evidentemente sbagliata. Per ovviare a questo inconveniente, si modifica la classe Term per produrre un errore di compilazione in casi come questo:

// ...
struct OPERATION_NOT_SUPPORTED;

template<typename T>
class Term {
public:
  // ...
  OPERATION_NOT_SUPPORTED operator&&(const Term<T>& term) const;
  OPERATION_NOT_SUPPORTED operator||(const Term<T>& term) const;

  OPERATION_NOT_SUPPORTED operator+(const Term<T>& term) const;
  OPERATION_NOT_SUPPORTED operator-(const Term<T>& term) const;
  OPERATION_NOT_SUPPORTED operator/(const Term<T>& term) const;
  OPERATION_NOT_SUPPORTED operator*(const Term<T>& term) const;
  OPERATION_NOT_SUPPORTED operator%(const Term<T>& term) const;

  OPERATION_NOT_SUPPORTED operator&(const Term<T>& term) const;
  OPERATION_NOT_SUPPORTED operator|(const Term<T>& term) const;
  OPERATION_NOT_SUPPORTED operator^(const Term<T>& term) const;

  OPERATION_NOT_SUPPORTED operator>>(int term) const;
  OPERATION_NOT_SUPPORTED operator<<(int term) const;
};

Il codice precedente ora genera un errore di compilazione; per ottenere il risultato desiderato è sufficiente racchiudere tra parentesi il primo termine:

int main() {
  // ...
  int i1 = 1;
  int i2 = 2;
  CHECK((i1 + 1) == 1);
  return 0;
}

// output:
// ...\check.cpp(299): (i1 + 1) == 1 evaluates to 2 == 1

La modifica proposta non copre tuttavia tutte le casistiche:

  CHECK((i1 == 1) && (i1 == i2)); // ok, does not compile!
  CHECK(i1 == 2 && i1 == i2);     // fails as expected, but expands to "1 == 2"

Mancata espansione degli argomenti delle chiamate a funzione

L'espansione non avviene per gli argomenti delle chiamate a funzione, come dimostra il frammento che segue:

// ...
bool isOdd(int i) {
  return i % 2;
}

int main() {
  int even = 2;
  CHECK(isOdd(even));
  return 0;
}

// output:
// ...\check..cpp(302): isOdd(even) evaluates to false

Risulta evidente che l'espansione non si propaga alla variabile even, ma si arresta al livello del valore ritornato dalla funzione isOdd.

Codice sorgente

Riferimenti

  1. Henney, K. "Rethinking Unit Testing in C++". Skills Matters: ACCU 2010: Rethinking Unit Testing in C++. <http://skillsmatter.com/podcast/agile-testing/kevlin-henney-rethinking-unit-testing-in-c-plus-plus>. Visitato il 20 Marzo 2012.
  2. Nash, P. "Catch". philsquared/Catch - github. <https://github.com/philsquared/Catch>. Visitato il 20 Marzo 2012.

Pagina modificata il 20/03/2012