Tests unitaires statiques

L'auteur de ce sujet a trouvé une solution à son problème.
Auteur du sujet

Ce sujet est à l'origine issu de PDP, et je n'avais pas de solution au problème. Il s'agit ici plus de recueillir des avis afin d'améliorer cette solution, ou de trouver d'autres approches.

Dans beaucoup de cas, j'utilise des types traits pour tester, par exemple, que le type d'un template donné est un type arithmétique. Parfois le comportement à tester est plus complexe. Comme toutes ces vérifictions sont faites statiquement, le code lui-même ne va pas compiler si l'on utilise quelque chose qui n'est pas prévu. Or, dans le cas d'une API que l'on fourni à un utilisateur final, cela devrait faire parti des choses à tester.

Le problème est double:

  • Je ne connais pas de bibliothèque de tests unitaires qui permette de gérer ces cas, c'est à dire que la bibliothèque permette de vérifier qu'un code ne compile pas et transforme cela en succès.
  • Intégrer ce comportement dans CMake. J'utilise par dessus Boost.test CMake + CTest. Or, lorsque l'on définit ses cibles avec CMake, si les tests sont activés, les tests vont être compilés et donc ces tests qui vérifient des propriétés statiques vont échouer et le processus entier de build va échouer.

Voici la solution que j'apporte:
J'ai trouvé un moyen d'obtenir ce que je veux sans que cela soit trop « hack », trop dépendant de CMake. Cela devrait en plus être portable:

Fichier staticWrapper.cpp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <cstdlib>

constexpr const bool Compile = true;
constexpr const bool NotCompile = false;

bool FATAL_STATIC_ASSERT(std::string command, bool expected)
{
    auto res = system(command.c_str());
    if(expected == Compile && res != Compile)
      exit(res);
    else if(expected == NotCompile && res == 0) // C'est moche mais avec Compile à la place de 0, le test passe...
      exit(1);
    else
      return 0;
}

int main()
{
  FATAL_STATIC_ASSERT(EO_STATIC_TEST_COMMAND, NotCompile);

  return 0;
}

Modification dans le fichier tests.cmake:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
set(STATIC_UNIT_TEST_LIST
        ut-staticAssert
 )

foreach(test ${STATIC_UNIT_TEST_LIST})
    add_executable(${test} ${TEST_DIR}/staticWrapper.cpp)
    target_link_libraries(${test} eo)
    set(STATIC_TEST_PARAM "${CMAKE_CXX_COMPILER} ${CMAKE_CXX_FLAGS} ${TEST_DIR}/${test}.cpp")
    target_compile_definitions(${test} PRIVATE -DEO_STATIC_TEST_COMMAND="${STATIC_TEST_PARAM}")
    add_test(${test} ${test})
endforeach()

C'est évidemment un brouillon mais l'idée est là.

Le test en lui même, ut-staticAssert:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <type_traits>

template<class T,
         class = typename std::enable_if<std::is_arithmetic<T>::value>::type>
class A
{};

class B {};

int main() {
  A<int> a;
  A<B> b;

  return 0;
}

Le test échoue si l'on supprime la ligne A<B> b; car cela va compiler correctement, et réussi avec la ligne car on s'attend à l'échec de la compilation. Le cahier des charges est respecté mais j'attends tout de même vos critiques ou proposition d'alternatives ou idées d'améliorations.

TODO:

  • Permettre plusieurs tests dans un seul fichier
  • Intégration de Boost.test (mais ça c'est plus pour mes besoins)
  • Gérer différents build type (release / debug, où les flags sont différents)
  • Factoriser un peu le code, qu'on puisse n'avoir qu'une liste de test, et spécifier un flag "static" par exemple.
+0 -0

Lu'!

Quand tu fais du code template, ta pire terreur, c'est les erreurs de compilation imbitables que tu te ramasses avec ce genre de code quand tu fais pas exactement ce qui est prévu. Les traits sont un premier moyen de palier à ce problème puisqu'en couplant à static_assert, tu peux ajouter des erreurs claires pour dire ce qui ne convient pas à ta classe template au développeur utilisateur.

Pour ça, en complément du côté enable_if(pred), on peut avoir l'implémentation enable_if(not_pred), le but étant de donner un message clair du nom respect de contrat du paramètre template passé. Pour s'assurer ensuite au test que le code ne serait pas généré, on peut avoir une implémentation comme ça :

1
static_assert(TEST_MODE || vrai_condition, "message")

En test mode, le static_assert serait inhibé et l'implémentation pourrait être un levé d'exception TEST_MODE::exception("le message qui va bien"), cela te permettrait d'assurer que dans la réalité, le code ne serait effectivement pas généré. L'exception levée pourrait également être le résultat du prédicat donné au enable_if pour s'assurer qu'il est bien à faux.

Édité par Ksass`Peuk

First : Always RTFM - "Tout devrait être rendu aussi simple que possible, mais pas plus." A.Einstein

+0 -0
Auteur du sujet

Bonjour Ksass`Peuk,

Pour moi, ce dont tu parles est un autre probleme: celui de fournir des message explicites aux erreurs de compilation statiques liees au non respect d'un contrat de l'API. Cependant c'est interessant egalement et j'aimerais bien avoir un exemple minimal de ce dont tu parles car je ne suis pas bien sur de voir.

En l'occurrence, les assertions statiques sont interessantes, mais avec un type traits basique comme celui presente dans mon exemple plus haut, j'echoue avant d'arriver au constructeur. Je pourrais faire quelque chose comme-ceci:

1
2
3
4
5
template<class T>
class A
{
A() { static_assert(std::enable_if<std::is_arithmetic<T>::value>::type, "FU"); }
};

Mais quid de la duplication de code en cas de constructeurs multiples ?

+0 -0

Cette réponse a aidé l'auteur du sujet

Après multiples tests et expérimentations, l'idée mise en secret était toute pourrie, la solution suivante est bien plus viable. Après vous pouvez regarder ce qu'on peut vaguement faire avec les traits si vous voulez.

Pour moi, ce dont tu parles est un autre probleme: celui de fournir des message explicites aux erreurs de compilation statiques liees au non respect d'un contrat de l'API. Cependant c'est interessant egalement et j'aimerais bien avoir un exemple minimal de ce dont tu parles car je ne suis pas bien sur de voir.

Höd

J'avais lancé l'idée sans vraiment essayer de produire un code réel, et finalement, ce n'est pas aussi simple que je le pensais, avec des namespaces et std::conditional (qui effectue le choix entre les deux namespace, l'un étant le cas "OK", l'autre le cas "ERREUR") puis par l'utilisation dans le namespace "ERREUR" de static_assert, on s'en sort mais le code n'est pas des plus pratiques.

Mais quid de la duplication de code en cas de constructeurs multiples ?

Höd

Ce point en revanche n'est pas un problème.

Voilà, un code minimaliste (j'ai pas peaufiné le bousin) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <exception>
#include <type_traits>

#ifdef TEST_MODE
  bool const TEST_MODE_VALUE = true;
#else
  bool const TEST_MODE_VALUE = false;
#endif

namespace test{
  class TypeContractException : std::exception{};
}

namespace valid{
  template<class T>
  class Truc{
  public:
    Truc(){}
    Truc(int){}
    Truc(float,int){}    
  };
}
namespace error{
  template<class T>
  class Truc{
  public:
    template<class ...Args>
    Truc(Args...){
      static_assert(TEST_MODE_VALUE, "message");
      throw test::TypeContractException{};
    }
  };
}

template<class T>
using Truc = typename std::conditional<std::is_default_constructible<T>::value,
                       valid::Truc<T>,
                       error::Truc<T>
                     >::type;

class A{
public:
  A() = delete;
};

int main(){
  Truc<int> t0;
  Truc<int> t1(42);
  Truc<int> t2(4.5,42);

  Truc<A> tt0;
  Truc<A> tt1(42);
  Truc<A> tt2(4.5,42);
}

Si on compile ce code sans le test mode d'activé, on aura une erreur de compilation. Si on le compile en test mode, on aura pas de problème de compilation mais on lèvera une exception à l'exécution.

Il y a peut être moyen de méta-programmé l'ensemble pour avoir une manière plus simple d'utiliser l'ensemble mais il y aura un peu de code dupliqué quasi à coup sûr.

Voilà une idée très simple et cohérente :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <exception>
#include <type_traits>

#ifdef TEST_MODE
  bool const TEST_MODE_VALUE = true;
#else
  bool const TEST_MODE_VALUE = false;
#endif

namespace test{
  class TypeContractException : std::exception{};
}

template<class T>
class Truc{
public:
  Truc(){
    check_type_contract();   
  }
  Truc(int){
    check_type_contract();   
  }
  Truc(float,int){
    check_type_contract();   
  }
private:
  static void check_type_contract(){
    if(!std::is_default_constructible<T>::value){
      static_assert(TEST_MODE_VALUE, "message");
      throw test::TypeContractException{};
    }
  }
};

class A{
public:
  A() = delete;
};

int main(){
  Truc<int> t0;
  Truc<int> t1(42);
  Truc<int> t2(4.5,42);

  Truc<A> tt0;
  Truc<A> tt1(42);
  Truc<A> tt2(4.5,42);
}

Si tu veux éviter de dupliquer la vérification de contrat (même si de toute façon, tu as des contrats à vérifier quoiqu'il arrive), tu peux hériter de manière privée d'une classe qui fera ce check pour toi :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<class T>
class ContractChecked{
public:
  inline ContractChecked(){ T::check_contract(); }
};

template<class T>
class Truc : private ContractChecked<Truc<T>>{
public:
  Truc(){}
  Truc(int){}
  Truc(float,int){}

private:
  friend class ContractChecked<Truc<T>>;

  static void check_contract(){
    if(!std::is_default_constructible<T>::value){
      static_assert(TEST_MODE_VALUE, "message");
      throw test::TypeContractException{};
    }
  }
};

(Le reste du code et le comportement obtenus sont peu ou prou les mêmes).

Édité par Ksass`Peuk

First : Always RTFM - "Tout devrait être rendu aussi simple que possible, mais pas plus." A.Einstein

+0 -0
Auteur du sujet

C'est franchement joli mais il y a ici une petite chose qui me chagrine.
Pour l'utilisateur final, la levee d'exception en cas de test ne le concerne pas et il serait mieux de l'avoir en dehors de la classe. Ne serait-ce que parce que je recois souvent des contributions et qu'il est deja difficile de faire respecter des standards assez stricts sans decourager les contributeurs externes…

C'est pourquoi j'avais favorise une approche via un wrapper qui test si la compilation echoue correctement, mais c'est moins joli. Je prefere clairement ton approche. :(

Édité par KFC

+0 -0

Pour l'utilisateur final, la levee d'exception en cas de test ne le concerne pas et il serait mieux de l'avoir en dehors de la classe.

Höd

Normalement, l'utilisateur d'une classe ne s'intéresse qu'à l'interface qu'elle propose. En l'occurrence quand tu dis "la levee d'exception en cas de test ne le concerne pas", non effectivement, c'est pour ça que c'est privé, ça ne le regarde pas, sauf s'il a fait une immense connerie, chose qu'on lui signal quand on est hors du mode "test". La levée possible d'exception n'est présente que dans le cas où l'on est en mode "test".

First : Always RTFM - "Tout devrait être rendu aussi simple que possible, mais pas plus." A.Einstein

+0 -0
Auteur du sujet

En fait, dans mon cas, l'utilisateur final s'y interesse tout de meme car il s'agit d'un framework open-source qui fonctionne en boite blanche. L'utilisateur final n'est donc pas un utilisateur lambda d'une API mais reellement un potentiel contributeur.

Édité par KFC

+0 -0

Mais même si ça fonctionne en boîte blanche, je te parle pas nécessairement d'utilisateur final, ce que je dis est vrai même si l'utilisateur de la classe c'est toi et même si ta classe est utilisée en interne de l'API.

La seule raison que tu as de t'intéresser à ce qui est privé, c'est quand tu dois modifier la classe en question (Truc, en l'occurrence) et si tu t'intéresses à modifier la classe en question, tu respectes son fonctionnement. Rappel : OCP.

First : Always RTFM - "Tout devrait être rendu aussi simple que possible, mais pas plus." A.Einstein

+0 -0
Auteur du sujet

Je n'avais pas vu la second version avec heritage privee, d'ou ma remarque.
De la sorte ca me va parfaitement. Au passage, est-ce quelque chose de standard ou idiomatique dans le sens ou cela aurait un nom ?

Parce que je n'ai jamais rien lu a ce sujet et ca me semble pourtant assez souhaitable d'avoir ce genre de tests sur les classes de politiques.

+0 -0

De la sorte ca me va parfaitement. Au passage, est-ce quelque chose de standard ou idiomatique dans le sens ou cela aurait un nom ?

Höd

C'est une bonne question, je n'en ai aucune idée.

First : Always RTFM - "Tout devrait être rendu aussi simple que possible, mais pas plus." A.Einstein

+0 -0

Je débarque un peu après la bataille j'ai l'impression. Perso, je reste simple : la lib a des assertions statiques, et certainement pas des exceptions (pour les choses décrites) : je préfère quand les choses ne compilent pas. Pour les tests, avoir ctest qui attend des compilations qui foirent me suffit.

Si vraiment tu veux tester plusieurs ruptures dans un source de test, je m'y prendrai comme un gros dégueulasse. Je ne touche pas à la lib qui continue à avoir ses assertions statiques. Ma priorité être non intrusif pour que le code livré et déployé ne sache pas qu'il existe un mode de test. Mais dans les tests, je commence par un #define static_assert(x, y) if !(x) throw std::logic_error(y). Je n'ai plus de scrupules à faire un #define private public dans les TU, alors vous imaginez un peu pour les assertions statiques.

Pour des vérifications d'invariants, j'ai des propositions de code dans le dernier article de ma série sur la PpC (l'url du source changera une fois fini). On retrouve des héritages côté NVI pour factoriser la vérification des invariants. Et pareil, je n'ai pas de nom pour ce truc qui ressemble au code proposé par Ksass`Peuk.

+1 -0
Vous devez être connecté pour pouvoir poster un message.
Connexion

Pas encore inscrit ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte