Tests en C ++ sans macros et mémoire dynamique

De nombreuses bibliothèques de test populaires, par exemple Google Test, Catch2, Boost.Test, sont fortement liées à l'utilisation de macros, donc à titre d'exemple de tests sur ces bibliothèques, vous voyez généralement une image comme celle-ci:


namespace { // Tests the default c'tor. TEST(MyString, DefaultConstructor) { const MyString s; EXPECT_STREQ(nullptr, s.c_string()); EXPECT_EQ(0u, s.Length()); } const char kHelloString[] = "Hello, world!"; // Tests the c'tor that accepts a C string. TEST(MyString, ConstructorFromCString) { const MyString s(kHelloString); EXPECT_EQ(0, strcmp(s.c_string(), kHelloString)); EXPECT_EQ(sizeof(kHelloString)/sizeof(kHelloString[0]) - 1, s.Length()); } // Tests the copy c'tor. TEST(MyString, CopyConstructor) { const MyString s1(kHelloString); const MyString s2 = s1; EXPECT_EQ(0, strcmp(s2.c_string(), kHelloString)); } } // namespace 

Les macros en C ++ se méfient, pourquoi sont-elles si florissantes dans les bibliothèques pour créer des tests?


La bibliothèque de tests unitaires doit fournir à ses utilisateurs un moyen d'écrire des tests afin que le runtime de test puisse les trouver et les exécuter d'une manière ou d'une autre. Lorsque vous réfléchissez à la façon de procéder, l'utilisation des macros semble être la plus simple. La macro TEST () définit généralement en quelque sorte une fonction (dans le cas de Google Test, la macro crée également une classe) et garantit que l'adresse de cette fonction pénètre dans un conteneur global.


La bibliothèque bien connue dans laquelle l'approche sans macro unique est implémentée est le tut-framework . Voyons son exemple dans le tutoriel:


 #include <tut/tut.hpp> namespace tut { struct basic{}; typedef test_group<basic> factory; typedef factory::object object; } namespace { tut::factory tf("basic test"); } namespace tut { template<> template<> void object::test<1>() { ensure_equals("2+2=?", 2+2, 4); } } 

L'idée qui sous-tend est assez intéressante et fonctionne, ce n'est pas très difficile. En bref, vous avez une classe de base qui implémente une fonction de modèle qui implique le paramétrage avec un entier:


 template <class Data> class test_object : public Data { /** * Default do-nothing test. */ template <int n> void test() { called_method_was_a_dummy_test_ = true; } } 

Maintenant, lorsque vous écrivez un tel test:


 template<> template<> void object::test<1>() { ensure_equals("2+2=?", 2+2, 4); } 

Vous créez en fait une spécialisation de méthode de test pour un nombre spécifique N = 1 (c'est exactement ce que le template<>template<> signifie). En appelant test<N>() le runtime de test peut comprendre s'il s'agissait d'un test réel ou d'un stub regardant la valeur called_method_was_a_dummy_test_ après l'exécution du test.


Ensuite, lorsque vous déclarez un groupe de test:


 tut::factory tf("basic test"); 

Premièrement, vous énumérez tous les test<N> à une certaine constante qui est câblée à la bibliothèque, et deuxièmement, par effet secondaire, vous ajoutez des informations sur le groupe au conteneur global (nom de groupe et adresses de toutes les fonctions de test).


Les exceptions sont utilisées comme conditions de test dans tut, donc la fonction tut::ensure_equals() simplement une exception si les deux valeurs qui lui sont passées ne sont pas égales, et l'environnement d'exécution de test intercepte une exception et considère le test comme ayant échoué. J'aime cette approche, elle devient immédiatement claire pour tout développeur C ++ où de telles assertions peuvent être utilisées. Par exemple, si mon test a créé un thread auxiliaire, il est inutile de placer des assertions là-bas, personne ne les rattrapera. De plus, il est clair pour moi que mon test devrait pouvoir libérer des ressources en cas d'exception, comme s'il s'agissait d'un code ordinaire protégé contre les exceptions.


En principe, la bibliothèque tut-framework semble assez bonne, mais il y a quelques inconvénients à sa mise en œuvre. Par exemple, pour mon cas, je voudrais que le test ait non seulement un nombre, mais aussi d'autres attributs, en particulier le nom, ainsi que la "taille" du test (par exemple, s'agit-il d'un test d'intégration ou d'un test unitaire). Cela peut être résolu dans le cadre de l'API tut, et même quelque chose existe déjà, et quelque chose peut être implémenté si vous ajoutez une méthode à l'API de la bibliothèque et l'appellez dans le corps du test pour définir l'un de ses paramètres:


 template<> template<> void object::test<1>() { set_name("2+2"); // Set test name to be shown in test report ensure_equals("2+2=?", 2+2, 4); } 

Un autre problème est que le lanceur de test tut ne sait rien d'un événement tel que le début d'un test. L'environnement exécute object::test<N>() et il ne sait pas à l'avance si le test est implémenté pour un N donné, ou s'il s'agit simplement d'un talon. Elle ne called_method_was_a_dummy_test_ que lorsque le test est terminé en analysant la valeur called_method_was_a_dummy_test_ . Cette fonctionnalité ne se montre pas très bien dans les systèmes CI, qui sont capables de regrouper la sortie que le programme a faite entre le début et la fin du test.


Cependant, à mon avis, la principale chose qui peut être améliorée (un "défaut fatal") est la présence de code auxiliaire supplémentaire requis pour écrire des tests. Il y a beaucoup de choses dans le tutoriel tut-framework: il est proposé de créer d'abord une certaine struct basic{} classe de struct basic{} , et de décrire les tests comme des méthodes d'objet associées à cela. Dans cette classe, vous pouvez définir les méthodes et les données que vous souhaitez utiliser dans le groupe de test, et le constructeur et le destructeur encadrent l'exécution du test, créant une chose telle que fixture à partir de jUnit. Dans ma pratique avec tut, cet objet est presque toujours vide, mais il traîne sur un certain nombre de lignes de code.


Donc, nous allons à l'atelier de vélo et essayons d'arranger l'idée sous la forme d'une petite bibliothèque.


Voici à quoi ressemble le fichier de test minimal dans la bibliothèque testée:


 // Test group for std::vector (illustrative purposes) #include "tested.h" #include <vector> template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime) { runtime->StartCase("emptiness"); std::vector<int> vec; tested::Is(vec.empty(), "Vector must be empty by default"); } template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime) { runtime->StartCase("AddElement"); std::vector<int> vec; vec.push_back(1); tested::Is(vec.size() == 1); tested::Is(vec[0] == 1); tested::FailIf(vec.empty()); } void LinkVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

En plus du manque de macros, le bonus est l'absence d'utilisation de la mémoire dynamique à l'intérieur de la bibliothèque.


Définition des cas de test


Pour l'enregistrement des tests, la magie élémentaire d'entrée de gamme est utilisée sur le même principe que tut. Quelque part dans testé.h, il existe une fonction passe-partout de ce type:


 template <int N> static void Case(IRuntime* runtime) { throw TheCaseIsAStub(); } 

Les cas de test écrits par les utilisateurs de bibliothèque sont simplement des spécialisations de cette méthode. La fonction est déclarée statique, c'est-à-dire dans chaque unité de traduction, nous créons des spécialisations qui ne se coupent pas mutuellement lors de la liaison.


Il existe une telle règle que vous devez d'abord appeler StartCase() , à laquelle vous pouvez passer des choses comme le nom du test et peut-être d'autres choses encore en développement.


Lorsqu'un test appelle runtime->StartTest() , des choses intéressantes peuvent se produire. Premièrement, si les tests sont maintenant en mode exécution, vous pouvez dire quelque part que le test a commencé son exécution. Deuxièmement, s'il existe un mode de collecte d'informations sur les tests disponibles, StartTest() un type spécial d'exception qui signifiera que le test est réel et non un talon.


Inscription


À un moment donné, vous devez collecter les adresses de tous les cas de test et les mettre quelque part. Dans testé, cela se fait à l'aide de groupes. Le constructeur de la classe testée :: Group fait cela comme un effet secondaire:


 static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); 

Le constructeur crée un groupe avec le nom spécifié et y ajoute tous les Case<N> qu'il trouve dans l'unité de traduction actuelle. Il s'avère que dans une unité de traduction, vous ne pouvez pas avoir deux groupes. Cela signifie également que vous ne pouvez pas diviser un groupe en plusieurs unités de traduction.


Le paramètre du modèle est le nombre de cas de test à rechercher dans l'unité de traduction actuelle pour le groupe créé.


Lien


Dans l'exemple ci-dessus, la création de l'objet testé :: Group () se produit à l'intérieur de la fonction que nous devons appeler à partir de notre application pour enregistrer les tests:


 void LinkStdVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

Une fonction n'est pas toujours requise, parfois vous pouvez simplement déclarer un objet de la classe tested::Group dans un fichier. Cependant, mon expérience est que l'éditeur de liens "optimise" parfois le fichier entier s'il est assemblé à l'intérieur de la bibliothèque, et aucune des applications principales n'utilise de caractères de ce fichier cpp:


 calc.lib <- calc_test.lib(calc_test.cpp) ^ ^ | | app.exe run_test.exe 

Lorsque calc_test.cpp n'est pas lié à partir de la source run_test.exe, l'éditeur de liens supprime simplement ce fichier de toute considération, ainsi que la création d'un objet statique, malgré le fait qu'il présente les effets secondaires dont nous avons besoin.


Si la chaîne résulte de run_test.exe, l'objet statique apparaîtra dans le fichier exécutable. Et peu importe comment cela se fait, comme dans l'exemple:


 void LinkStdVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

ou alors:


 static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); void LinkStdVectorTests() { } 

La première option, à mon avis, est meilleure car le constructeur est appelé après le début de main () et l'application a un certain contrôle sur ce processus.


Je pense que ce paramètre de béquilles est requis pour toute bibliothèque de tests unitaires qui utilise des variables globales et des effets secondaires du constructeur pour créer une base de données de test. Cependant, cela peut probablement être évité en liant la bibliothèque de test avec la clé --whole-archive (un analogue dans MSVC n'est apparu que dans Visual Studio 2015.3).


Macros


J'ai promis qu'il n'y aurait pas de macros, mais c'est - CASE_COUNTER . L'option de travail est qu'elle est utilisée par __COUNTER__ , une macro que le compilateur incrémente d'une unité à chaque fois qu'elle est utilisée dans l'unité de traduction.
Pris en charge par GCC, CLANG, MSVC, mais pas la norme. Si cela est frustrant, voici quelques alternatives:


  • utilisez les chiffres 0, 1, 2
  • utilisez la norme __LINE__ .
  • utilisez la magie constexpr de niveau 80. Vous pouvez rechercher "constexpr counter" et essayer de trouver le compilateur sur lequel il fonctionnera.

Le problème avec __LINE__ est que l'utilisation de grands nombres dans les options de modèle crée une grande taille de fichier exécutable. C'est pourquoi j'ai limité le type du modèle de caractère signé à 128 comme nombre maximal de tests dans le groupe.


Défaillance de la mémoire dynamique


Il s'est avéré que lors de l'enregistrement des tests, vous ne pouvez pas utiliser la mémoire dynamique, que j'ai utilisée. Il est possible que votre environnement ne dispose pas de mémoire dynamique ou que vous utilisiez la recherche de fuites de mémoire dans les cas de test, donc l'intervention de l'environnement d'exécution du test n'est pas ce dont vous avez besoin. Google Test a du mal avec cela, voici un extrait de là:


 // Use the RAII idiom to flag mem allocs that are intentionally never // deallocated. The motivation is to silence the false positive mem leaks // that are reported by the debug version of MS's CRT which can only detect // if an alloc is missing a matching deallocation. // Example: // MemoryIsNotDeallocated memory_is_not_deallocated; // critical_section_ = new CRITICAL_SECTION; class MemoryIsNotDeallocated 

Et nous ne pouvons tout simplement pas créer de difficultés.


Comment obtenir alors une liste de tests? Ce sont des internes plus techniques, qui sont plus faciles à voir dans le code source, mais je vous le dirai quand même.


Lors de la création d'un groupe, sa classe recevra un pointeur vers la fonction tested::CaseCollector<CASE_COUNTER>::collect , qui collectera tous les tests unitaires de traduction dans une liste. Voici comment cela fonctionne:


 // Make the anonymouse namespace to have instances be hidden to specific translation unit namespace { template <Ordinal_t N> struct CaseCollector { // Test runtime that collects the test case struct CollectorRuntime final : IRuntime { void StartCase(const char* caseName, const char* description = nullptr) final { // the trick is exit from test case function into the collector via throw throw CaseIsReal(); } }; // Finds the Case<N> function in current translation unit and adds into the static list. It uses the // reverse order, so the case executed in order of appearance in C++ file. static CaseListEntry* collect(CaseListEntry* tail) { CaseListEntry* current = nullptr; CollectorRuntime collector; try { Case<N>(&collector); } catch (CaseIsStub) { current = tail; } catch (CaseIsReal) { s_caseListEntry.CaseProc = Case<N>; s_caseListEntry.Next = tail; s_caseListEntry.Ordinal = N; current = &s_caseListEntry; } return CaseCollector<N - 1>::collect(current); } private: static CaseListEntry s_caseListEntry; }; // This static storage will be instantiated in any cpp file template <Ordinal_t N> CaseListEntry CaseCollector<N>::s_caseListEntry; } 

Il s'avère que dans chaque unité de traduction de nombreuses variables statiques du type CaseListEntry CaseCollector \ :: s_caseListEntry sont créées, qui sont des éléments de la liste de tests, et la méthode collect () collecte ces éléments dans une liste connectée individuellement. De la même manière, la liste forme des groupes de tests, mais sans schémas ni récursivité.


La structure


Les tests nécessitent une liaison différente, telle que la sortie vers la console en lettres rouges Échec, la création de rapports de test dans un format compréhensible pour CI ou GUI dans lequel vous pouvez voir la liste des tests et exécuter ceux sélectionnés - en général, beaucoup de choses. J'ai une vision de la façon dont cela peut être fait, ce qui est différent de ce que j'ai vu plus tôt dans la bibliothèque de tests. La revendication concerne principalement les bibliothèques qui se disent "en-tête uniquement", tout en incluant une grande quantité de code, qui n'est essentiellement pas destiné aux fichiers d'en-tête.


L'approche que je suppose est que nous divisons la bibliothèque en front-end - c'est testé.h et les bibliothèques back-end elles-mêmes. Pour écrire des tests, vous n'avez besoin que de testing.h, qui est maintenant C ++ 17 (en raison de std :: std :: string_view), mais il est supposé qu'il y aura C ++ 98. Tested.h effectue en fait l'enregistrement et la recherche de tests, une option de lancement minimalement pratique, ainsi que la possibilité d'exporter des tests (groupes, adresses des fonctions de cas de test). Les bibliothèques d'arrière-plan qui n'existent pas encore peuvent faire tout ce dont elles ont besoin en termes de sortie des résultats et de lancement à l'aide de la fonctionnalité d'exportation. De la même manière, vous pouvez adapter le lancement aux besoins de votre projet.


Résumé


La bibliothèque testée ( code github ) a encore besoin d'une certaine stabilisation. Dans un avenir proche, ajoutez la possibilité d'exécuter des tests asynchrones (nécessaires pour les tests d'intégration dans WebAssembly) et indiquez la taille des tests. À mon avis, la bibliothèque n'est pas encore tout à fait prête à être utilisée en production, mais j'ai soudainement passé beaucoup de temps et la scène est venue s'arrêter, reprendre mon souffle et demander les commentaires de la communauté. Seriez-vous intéressé à utiliser ce type de bibliothèque? Peut-être qu'il y a d'autres idées dans l'arsenal C ++ car il serait possible de créer une bibliothèque sans macros? Une telle déclaration du problème est-elle intéressante du tout?

Source: https://habr.com/ru/post/fr434906/


All Articles