Automatiser les tests unitaires

Ce qui suit applique plusieurs techniques de grande personne. Si vous trouvez le propos intéressant mais souhaitez en savoir plus sur les techniques sous-jacentes, je vous invite à lire sur les classes incopiables, les foncteurs, la métaprogrammation, les pointeurs de méthodes d'instances et l'idiome CRTP entre autres choses.

Les tests unitaires sont ces tests qu'on applique à des objets ou à de petites entités atomiques d'un programme pour vérifier si ces entités rencontrent un certain nombre d'exigences. Typiquement, le contrat de comportement associé à une classe donnée ou à ses instances sera défini au préalable, et procéder à des tests unitaires impliquera de vérifier le respect de ce contrat.

Les tests possibles sont nombreux, en incluent entre autres possibilités les tests en boîte noire (vérification du respect des spécifications de l'entité sans examiner le détail de l'implémentation) et les tests en boîte blanche (vérification du comportement correct de l'objet en connaissant son implémentation et en tenant compte de celle-ci).

Ce qui suit montre une technique simple pour injecter des facilités de tests unitaires automatiques dans des classes C++.

Le programme principal que nous souhaitons obtenir ressemblera à celui proposé à droite. Notre souhait est que toutes les classes utilisées dans le programme (ici, on présumera que le programme se limite à main(), mais la technique proposée ratisse beaucoup plus large) et pour lesquelles des tests unitaires sont développés soient automatiquement testées par l'invocation de la méthode proceder(ostream&).

Le flux en sortie passé à proceder() permettra au code client de choisir l'endroit où seront envoyés les messages générés par les tests.

// inclusions pour A, B et C
#include "RegistreTests.h"
#include <iostream>
int main() {
   using std::cout;
   A a, a0;
   //B b;
   C c;
   RegistreTests::get().proceder(cout);
}

Les tests unitaires sont présumés indépendants les uns des autres, donc nous ne présumerons pas de l'ordre selon lequel les divers tests seront réalisés.

Notez que main() déclare deux instances de A, aucune instance de B et une instance de C. Notre souhait est que la classe A soit testée une seule fois (même si on en trouve deux instances dans le programme), que la classe B ne soit pas testée (parce qu'elle n'est pas utilisée) et que la classe C soit testée une seule fois aussi.

Nous voudrons enfin que la stratégie de test appliquée dépende des types à tester, mais nous ne voulons pas d'une stratégie intrusive où tous les types à tester devraient être polymorphiques et dériver d'un parent commun. Une stratégie intrusive aurait entre autres défauts celui de dénaturer les types valeurs en accroissant artificiellement l'espace que leurs instances occupent en mémoire, ce qui pourrait aller jusqu'à invalider certains tests.

Dernier détail : nous voudrons que la mécanique de tests elle-même produise des messages d'erreurs standardisés si des exceptions sont levées lors des tests unitaires de certains types, tout en faisant en sorte que la mécanique de tests soit neutre face aux exceptions (donc que les exceptions levées soient notées par la mécanique de tests mais filtrent tout de même vers le code client sans avoir été modifiées entre-temps).

Nommer les types de manière non ambiguë

C++ offre, à travers les mécanismes de Run Time Type Information (RTTI) que sont le type std::type_info et l'opérateur typeid, une manière localement homogène d'identifier le nom des types. J'indique explicitement localement homogène du fait que les noms obtenus par ce mécanisme peuvent varier d'un compilateur à l'autre ou d'une version à l'autre d'un même compilateur. Si nous souhaitons générer des messages uniformes pour signaler la levée d'exceptions lors des tests pour un type donné, il sera pertinent pour nous de mettre en place une stratégie homogène de nommage dans nos programmes.

Une technique simple pour obtenir un schème homogène de manière persistante pour un système donné est d'utiliser des traits. Le fichier type_name.h montre comment on peut y arriver et définit une macro qui simplifie la génération d'associations entre un type et le nom que le système lui attribuera (non, je n'aime pas les macros moi non-plus, mais ça allégeait le travail dans ce cas-ci et elles ne sont pas strictement nécessaires).

Fichier type_name.h
#ifndef TYPE_NAME_H
#define TYPE_NAME_H
#include <string_view>
//
// Cas par défaut (volontairement laissé indéfini)
//
template <class>
   struct type_name;
//
// Une macro pour alléger la génération des traits
//
#define ADD_TYPE_NAME(type,name) \
   template <> \
      struct type_name<type> { \
         static constexpr std::string_view value() { \
            return #name \
         } \
      };
//
// Quelques noms de types communs
//
ADD_TYPE_NAME(signed char,signed char)
ADD_TYPE_NAME(unsigned char,unsigned char)
ADD_TYPE_NAME(char,char)
ADD_TYPE_NAME(unsigned short,unsigned short)
ADD_TYPE_NAME(short,short)
ADD_TYPE_NAME(unsigned int,unsigned int)
ADD_TYPE_NAME(int,int)
ADD_TYPE_NAME(unsigned long,unsigned long)
ADD_TYPE_NAME(long,long)
ADD_TYPE_NAME(float,float)
ADD_TYPE_NAME(double,double)
ADD_TYPE_NAME(long double,long double)
ADD_TYPE_NAME(std::string,string)
// etc.
#endif

Notez que la version proposée ici ne définit pas de nom par défaut pour un type donné, mais si vous jugez pertinent d'offrir ce service, alors il vous suffira de retourner typeid(T).name() dans le cas général.

Si vous faites ce choix, alors le programme croîtra en taille dû à l'introduction des mécanismes RTTI et des informations qui l'accompagnent.

évidemment, si votre programme avait déjà recours à ces mécanismes, alors vous pourrez les utiliser à coût presque zéro.

//
// Remplacer ceci...
// 
template <class>
   struct type_name;
//
// ...par cela
//
template <class T>
   struct type_name    {
      static const auto value() {
         //
         // Activer RTTI pour utiliser ceci
         //
         return typeid(T).name();
      }
   };

Le registre de tests

Le registre de tests (classe RegistreTests) sera un singleton responsable de connaître la liste des tests à réaliser. Les types à tester s'y enregistreront à travers une classe polymorphique accessoire, UnitTestableRoot, dont les enfants seront génériques.

Sur demande, le singleton procédera en séquence aux tests unitaires sur chacun des types inscrits.

Fichier RegistreTests.h
#ifndef REGISTRETESTS_H
#define REGISTRETESTS_H
#include "UnitTestableRoot.h"
#include <vector>
#include <functional>
#include <algorithm>
#include <memory>
#include <iosfwd>
class RegistreTests {
   RegistreTests() = default;
   std::vector<std::unique_ptr<UnitTestableRoot>> units;
public:
   static RegistreTests &get() noexcept {
      static RegistreTests singleton;
      return singleton;
   }
   template <class T, class F>
      void enregistrer() {
         units.push_back(std::make_unique<UnitTestableImpl<T, F>>());
      }
   void proceder(std::ostream &os) {
      for(auto & p : units) proceder(os);
   }
};
#endif

Quelques détails à noter :

Encadrement des objets de tests

Les objets permettant d'encadrer et d'intégrer les diverses tstratégies de tests unitaires dans un tout cohérent sont la paire de classes UnitTestableRoot, racine polymorphique abstraite, et UnitTestableImpl<T,F>, enfant générique implémentant une procédure de test unitaire spécifique au type T et passant par la fonction F.

Fichier UnitTestableRoot.h

 

Le recours à une racine polymorphique unique permet au registre de tests de regrouper tous les tests sous un même chapiteau.

L'implémentation à droite applique le schéma de conception Template Method avec une méthode publique non polymorphique (proceder()) et une méthode polymorphique (abstraite dans ce cas-ci), proceder_impl(), que les enfants spécialiseront. Ceci permet, au besoin, d'encadrer les appels à proceder().

Les implémentations spécifiques à chaque paire faite d'un type T et d'une opération de test F seront représentées par des instances de la classe UnitTestableImpl<T,F>.

Chacune de ces classes génériques dérivera de UnitTestableRoot et implémentera l'invocation de F::operator() et, si les tests lèvent une exception, la génération d'un message significatif reposant sur le nom du type pour lequel les tests ont mené à cette levée d'exception.

#ifndef UNITTESTABLEROOT_H
#define UNITTESTABLEROOT_H
#include <string_view>
#include <iosfwd>
struct UnitTestableRoot {
   // template methode pattern
   void proceder(std::ostream &os) {
      proceder_impl(os);
   }
   virtual ~UnitTestableRoot() = default;
protected:
   void message(std::ostream &os, std::string_view);
   virtual void proceder_impl(std::ostream &) = 0;
};
template <class T, class F>
   class UnitTestableImpl
      : public UnitTestableRoot {
      F fct_;
      // méthode privée!
      void proceder_impl(std::ostream &os) {
         try {
            fct_(os);
         } catch (...) {
            message(os, type_name<T>::value());
            throw; // exception neutral
         }
      }
   };
#endif
Fichier UnitTestableRoot.cpp

Notez que message() fait partie de la classe parent, non générique, ce qui permet de la définir dans le fichier source et de ne pas exposer ses détails d'implémentation.

L'implémentation proposée à droite écrit le message sur le flux utilisé pour les tests, mais on aurait aussi pu utiliser un fichier de journalisation (par exemple std::clog) ou le flux d'erreur standard (std::cerr).

#include "UnitTestableRoot.h"
#include <string_view>
#include <iostream>
using namespace std;

void UnitTestableRoot::message(ostream &os, string_viewname) {
   os << "Exception non attrapée lors des tests sur \""
      << name << "\"" << endl;
}

Automatiser l'enregistrement des types à tester

Nous ne voulons pas inscrire tous les types d'un programme à la liste de ceux pour lesquels des tests unitaires sont requis, mais nous voulons par contre faire en sorte que l'inscription au registre des tests soit aussi simple que le serait une simple annotation qui indiquerait « il faut tester ce type ».

Fichier UnitTestable.h

Pour en arriver à ce point, nous définirons la classe UnitTestable<T,F>.

Si la classe UnitTestable<T,F> est instanciée pour un type T donné et une fonction de test F donnée, alors une instance de UnitTestableImpl<T,F> sera inscrite dans le registre de tests.

La classe UnitTestable est générique mais la mécanique de la méthode de classe Enregistrement() utilise std::call_once pour éviter l'inscription au registre de multiples instances de UnitTestableImpl<T,F>.

#ifndef UNITTESTABLE_H
#define UNITTESTABLE_H
#include "RegistreTests.h"
#include <mutex>
template <class>
   struct relais_statique;
template <class T, class F = relais_statique<T>>
   class UnitTestable {
      static inline std::once_flag fanion;
      static void enregistrement() {
         std:::call_once(fanion, []{ RegistreTests::get().enregistrer<T,F>(); });
      }
   protected:
      UnitTestable() {
         enregistrement();
      }
   };
#endif

Notez le recours à relais_statique<T>. Bien que les clients de UnitTestable<T,F> puissent suppléer leur propre fonction de test F, il peut être utile d'ajouter une indirection générique (donc statique) permettant à des gens de choisir la fonction de test souhaitée de manière flexible. En fait, si nous voulons utiliser certains idiomes de programmation, cette indirection sera même nécessaire, comme nous le verrons ci-dessous.

Quelques classes testables

Dans les exemples qui suivent, nous utiliserons ce schème de foncteur statique indirect (passant au besoin par un relais_statique<T>) pour intégrer deux approches distinctes aux tests unitaires.

Les classes à tester

Imaginons que la classe A telle que proposée à droite doive être soumise à des tests unitaires, et présumons que ces tests soient implémentés dans la méthode de classe A::UnitTest(ostream&).

La manière la plus simple de définir cette relation est par héritage privé, du fait que A est UnitTestable (ce qui justifie l'héritage) mais que cela ne concerne qu'elle (ce qui justifie l'héritage privé). Notez le recours à l'idiome CRTP pour spécifier que UnitTestable<A> est la parent de A.

Notez aussi que A::UnitTest() est une méthode de classe privée. Nous aurions aussi pu la rendre publique, mais notre schème ici veut que les tests soient faits par le registre de tests et sur demande seulement, alors laisser la méthode privée et faire de relais_statique<A> un ami de A, donc de l'habiliter à invoquer une méthode privée de A, permet de réduire la surface publique d'exposition de la classe A.

#include "type_name.h"
#include "UnitTestable.h"
#include <iosfwd>
class A : UnitTestable<A> {
   friend struct relais_statique<A>;
   static void UnitTest(std::ostream &os) {
      // tests divers sur la classe A
      os << "Tests unitaires sur A: Ok" << endl;
   }
   // etc.
};
template <>
   struct relais_statique<A> {
      void operator()(std::ostream &os) {
         A::UnitTest(os);
      }
   };
ADD_TYPE_NAME (A,class A)

Notez que nous n'aurions pas pu indiquer que A dérive de UnitTest<A,&A::UnitTest> du fait qu'au moment d'indiquer le parent d'une classe, seul le nom de la classe enfant existe et le compilateur n'aurait pu comprendre, à ce stade, ce que signifie &A::UnitTest.

class A : UnitTestable<A, &A::UnitTest> // illégal, nom inconnu à ce stade!
{
   // ...
};

En C++, un nom doit être introduit dans un programme avant d'y être utilisé. Ici, le compilateur ne pourrait pas savoir si A::UnitTest est une méthode (encore moins sa signature!), un attribut ou autre chose d'étrange, ce qui lui rendrait impossible la tâche de générer le code du parent.

De même, nous n'aurions pas pu déclarer un prototype de méthode hors de la déclaration de la classe A elle-même, car ce serait contraire aux règles du langage.

class A;
void A::UnitTest(ostream&); // illégal!
class A : UnitTestable<A, &A::UnitTest>
{
   // ...
};

Par contre, le recours à relais_statique, qui est générique, retarde la génération du recours à A::UnitTest() au moment où le compilateur rencontrera le besoin de générer l'opérateur () de relais_statique<A>, et à ce moment il saura tout ce qu'il a besoin de savoir.

Le recours à une indirection à travers la classe générique relais_statique n'est donc pas un simple artifice. Il existe des alternatives mais elles ne sont pas très jolies.

Les classes à tester (suite)

Les classes B et C sont aussi des raffinements de UnitTestable.

Notez que si la classe B a été développée dans un milieu anglophone, d'où le nom de méthode B::UnitTest(), il se trouve que C utilise un autre standard de nomenclature, francophone celui-là, et sa méthode de tests unitaires se nomme C::TestUnitaire().

L'indirection statique relais_statique<C> isole le code client de ces détails en assurant le relais vers la méthode appropriée. Le code du registre de tests obtient, à travers relais_statique, un point de vue homogène sur le code de tests unitaires, toute classe confondue.

Notez que l'approche non intrusive fait qu'il serait possible de tester une classe X provenant d'un tiers à travers la même infrastructure : il suffirait de définir une classe d'accompagnement (disons TesterX) qui implémenterait une méthode de tests unitaires pour X, puis de faire de la classe TesterX un cas particulier (privé) de UnitTestable<TesterX>.

// ...
class B : UnitTestable<B> {
   friend struct relais_statique<B>;
   static void UnitTest(std::ostream &os) {
      // tests sur B...
      os << "Tests unitaires sur B: Ok" << endl;
   }
};
template <>
   struct relais_statique<B> {
      void operator()(std::ostream &os) {
         B::UnitTest(os);
      }
   };
ADD_TYPE_NAME (B,struct B)

class C : UnitTestable<C> {
   friend struct relais_statique<C>;
   static void TestUnitaire(std::ostream &os) {
      //throw 0;
      os << "Tests unitaires sur C: Ok" << endl;
   }
};
template <>
   struct relais_statique<C> {
      void operator()(std::ostream &os) {
         C::TestUnitaire(os);
      }
   };
ADD_TYPE_NAME (C,struct C)

Valid XHTML 1.0 Transitional

CSS Valide !