Récemment, zerocost a écrit un article intéressant, «Tests en C ++ sans macros et mémoire dynamique» , qui traite d'un cadre minimaliste pour tester le code C ++. L'auteur a (presque) réussi à éviter l'utilisation de macros pour enregistrer les tests, mais à la place d'eux, des modèles «magiques» sont apparus dans le code, qui me semblent personnellement, désolé, inimaginablement laid. Après avoir lu l'article, j'ai eu un vague sentiment d'insatisfaction, car je savais ce qui pouvait être mieux fait. Je ne pouvais pas me souvenir immédiatement où, mais j'ai certainement vu le code de test, qui ne contient pas un seul caractère supplémentaire pour les enregistrer:
void test_object_addition() { ensure_equals("2 + 2 = ?", 2 + 2, 4); }
Enfin, je me suis souvenu que ce cadre s'appelle Cutter et qu'il utilise une manière géniale pour identifier les fonctions de test à sa manière.
(KDPV tiré du site Web de Cutter sous CC BY-SA.)
Quel est le truc?
Le code de test est assemblé dans une bibliothèque partagée distincte. Les fonctions de test sont extraites des symboles de bibliothèque exportés et identifiées par des noms. Les tests sont effectués par un utilitaire externe spécial. Sapienti était assis.
$ cat test_addition.c #include <cutter.h> void test_addition() { cut_assert_equal_int(2 + 2, 5); }
$ cc -shared -o test_addition.so \ -I/usr/include/cutter -lcutter \ test_addition.c
$ cutter . F ========================================================================= Failure: test_addition <2 + 2 == 5> expected: <4> actual: <5> test_addition.c:5: void test_addition(): cut_assert_equal_int(2 + 2, 5, ) ========================================================================= Finished in 0.000943 seconds (total: 0.000615 seconds) 1 test(s), 0 assertion(s), 1 failure(s), 0 error(s), 0 pending(s), 0 omission(s), 0 notification(s) 0% passed
Voici un exemple tiré de la documentation de Cutter . Vous pouvez parcourir en toute sécurité tout ce qui concerne les Autotools et ne regarder que le code. Le cadre est un peu étrange, oui, comme tout le japonais.
Je n'entrerai pas dans trop de détails sur les fonctionnalités d'implémentation. Je n'ai pas non plus de code à part entière (et même au moins brouillon), car personnellement je n'en ai pas vraiment besoin (dans Rust, tout est sorti de la boîte). Cependant, pour les personnes intéressées, cela peut être un bon exercice.
Détails et options de mise en œuvre
Considérez certaines des tâches que vous devez résoudre lors de l'écriture d'un cadre de test à l'aide de l'approche Cutter.
Obtention des fonctions exportées
Tout d'abord, vous devez accéder aux fonctions de test d'une manière ou d'une autre. Bien entendu, la norme C ++ ne décrit pas du tout les bibliothèques partagées. Windows a récemment acquis un sous-système Linux, qui permet de réduire les trois principaux systèmes d'exploitation à POSIX. Comme vous le savez, les systèmes POSIX fournissent les fonctions dlopen()
, dlsym()
, dlclose()
, avec lesquelles vous pouvez obtenir l'adresse de la fonction, connaître le nom de son symbole, et ... c'est tout. La liste des fonctions contenues dans la bibliothèque chargée n'est pas divulguée par POSIX.
Malheureusement (quoique plutôt heureusement), il n'existe pas de moyen standard et portable pour découvrir toutes les fonctions exportées depuis la bibliothèque. Peut-être, le fait que le concept de bibliothèque n'existe pas sur toutes les plateformes (lire: embarqué) est en quelque sorte impliqué ici. Mais ce n'est pas l'essentiel. L'essentiel est que vous devez utiliser des fonctionnalités spécifiques à la plate-forme.
En première approximation, vous pouvez simplement appeler l'utilitaire nm :
$ cat test.cpp void test_object_addition() { }
$ clang -shared test.cpp
$ nm -gj ./a.out __Z20test_object_additionv dyld_stub_binder
analyser sa sortie et utiliser dlsym()
.
Pour une introspection plus approfondie, des bibliothèques comme libelf , libMachO , pe-parse sont utiles, vous permettant d'analyser par programme des fichiers exécutables et des bibliothèques de plateformes qui vous intéressent. En fait, nm et l'entreprise les utilisent.
Filtrage des fonctions de test
Comme vous l'avez peut-être remarqué, les bibliothèques contiennent des caractères étranges:
__Z20test_object_additionv dyld_stub_binder
C'est ce qu'est __Z20test_object_additionv
, lorsque nous avons appelé la fonction simplement test_object_addition
? Et quel est ce dyld_stub_binder
gauche?
Les caractères " __Z20...
" __Z20...
sont la soi-disant décoration de nom (dénomination du nom). Fonction de compilation C ++, rien ne peut être fait, vivre avec. C'est ce que les fonctions sont appelées du point de vue du système (et dlsym()
). Afin de les montrer à une personne dans leur forme normale, vous pouvez utiliser des bibliothèques comme libdemangle . Bien sûr, la bibliothèque dont vous avez besoin dépend du compilateur que vous utilisez, mais le format de décoration est généralement le même dans le cadre de la plateforme.
Quant aux fonctions étranges comme dyld_stub_binder
, ce sont aussi des fonctionnalités de plateforme qui devront être prises en compte. Vous n'avez pas besoin d'appeler de fonctions lors du démarrage des tests, car il n'y a pas de poisson là-bas.
Une suite logique de cette idée est de filtrer la fonction par nom. Par exemple, vous ne pouvez exécuter que des fonctions avec test
dans le nom. Ou simplement des fonctions de l'espace de noms de tests
. Et utilisez également des espaces de noms imbriqués pour regrouper les tests. Il n'y a pas de limite à votre imagination.
Passer le contexte d'un test exécutable
Les fichiers objets avec des tests sont collectés dans une bibliothèque partagée, dont l'exécution du code est entièrement contrôlée par un utilitaire-driver externe - cutter
pour Cutter. Par conséquent, les fonctions de test internes peuvent l'utiliser.
Par exemple, le contexte d'un test exécutable ( IRuntime
dans l'article d'origine) peut être transmis en toute sécurité via une variable globale (thread-local). Le conducteur est responsable de la gestion et du passage du contexte.
Dans ce cas, les fonctions de test ne nécessitent pas d'arguments, mais conservent toutes les fonctionnalités avancées, telles que la dénomination arbitraire des cas testés:
void test_vector_add_element() { testing::description("vector size grows after push_back()"); }
La fonction description()
accède à l' IRuntime
conditionnel via une variable globale et peut ainsi transmettre un commentaire au framework pour une personne. La sécurité d'utilisation du contexte global est garantie par le framework et n'est pas de la responsabilité du rédacteur de test.
Avec cette approche, il y aura moins de bruit dans le code avec le transfert de contexte aux déclarations de comparaison et aux fonctions de test internes qui peuvent devoir être appelées depuis la principale.
Constructeurs et destructeurs
Étant donné que l'exécution des tests est entièrement contrôlée par le pilote, il peut exécuter du code supplémentaire autour des tests.
La bibliothèque Cutter utilise pour cela les fonctions suivantes:
cut_setup()
- avant chaque test individuelcut_teardown()
- après chaque test individuelcut_startup()
- avant d'exécuter tous les testscut_shutdown()
- après la fin de tous les tests
Ces fonctions sont appelées uniquement si elles sont définies dans le fichier de test. Vous pouvez y mettre la préparation et le nettoyage de l'environnement de test (fixture): la création des fichiers temporaires nécessaires, la configuration difficile des objets testés, et d'autres antipatterns de test.
Pour C ++, il est possible de proposer une interface plus idiomatique:
- plus orienté objet et type sûr
- avec une meilleure prise en charge du concept RAII
- utilisation de lambdas pour une exécution différée
- impliquant un contexte d'exécution de test
Mais pour l'instant, j'y repense en détail maintenant.
Exécutables de test autonomes
Cutter utilise une approche de bibliothèque partagée pour plus de commodité. Divers tests sont compilés dans un ensemble de bibliothèques qu'un utilitaire de test séparé trouve et exécute. Naturellement, si vous le souhaitez, le code entier du pilote de test peut être intégré directement dans le fichier exécutable, en obtenant les fichiers séparés habituels. Cependant, cela nécessitera une collaboration avec le système de construction pour organiser la mise en page de ces fichiers exécutables de la bonne manière: sans supprimer les fonctions «inutilisées», avec les bonnes dépendances, etc.
Autre
Cutter et d'autres frameworks ont également beaucoup d'autres choses utiles qui peuvent vous faciliter la vie lors de l'écriture de tests:
- instructions de test flexibles et extensibles
- création et obtention de données de test à partir de fichiers
- études de trace de pile, gestion des exceptions et des drop
- «niveaux de ventilation» personnalisables des tests
- exécution de tests dans plusieurs processus
Il vaut la peine de revenir sur les cadres existants lors de l'écriture de votre vélo. UX est un sujet beaucoup plus profond.
Conclusion
L'approche utilisée par le framework Cutter permet d'identifier des fonctions de test avec une charge cognitive minimale sur le programmeur: il suffit d'écrire des fonctions de test et c'est tout. Le code ne nécessite pas l'utilisation de modèles ou de macros spéciaux, ce qui augmente sa lisibilité.
Les fonctionnalités d'assemblage et d'exécution de tests peuvent être cachées dans des modules réutilisables pour des systèmes d'assemblage tels que Makefile, CMake, etc. Des questions sur un assemblage séparé de tests devront toujours être posées d'une manière ou d'une autre.
Les inconvénients de cette approche incluent la difficulté de placer des tests dans le même fichier (la même unité de traduction) que le code principal. Malheureusement, dans ce cas, sans conseils supplémentaires, il n'est plus possible de déterminer quelles fonctions doivent être lancées et lesquelles ne le sont pas. Heureusement, en C ++, il est généralement habituel de distribuer les tests et l'implémentation dans différents fichiers.
Quant à l'élimination définitive des macros, il me semble qu'en principe elles ne doivent pas être abandonnées. Les macros permettent, par exemple, d'écrire des instructions de comparaison plus courtes, en évitant la duplication de code:
void test_object_addition() { ensure_equals(2 + 2, 5); }
mais en gardant en même temps le même contenu informationnel du problème en cas d'erreur:
Failure: test_object_addition <ensure_equals(2 + 2, 5)> expected: <5> actual: <4> test.c:5: test_object_addition()
Le nom de la fonction testée, le nom du fichier et le numéro de ligne du début de la fonction peuvent en théorie être extraits des informations de débogage contenues dans la bibliothèque collectée. La valeur attendue et réelle des expressions comparées est connue de la fonction ensure_equals()
. La macro vous permet de "restaurer" l'orthographe d'origine de l'instruction de test, à partir de laquelle il est plus clair pourquoi la valeur 4
est attendue.
Cependant, ce n'est pas pour tout le monde. L'avantage des macros pour le code de test s'arrête-t-il là? Je n'ai pas encore vraiment pensé à ce moment, ce qui peut s'avérer être un bon terrain pour perversions recherche. Une question beaucoup plus intéressante: est-il possible de faire en quelque sorte un cadre simulé pour C ++ sans macros?
Le lecteur attentif a également noté qu'il n'y a vraiment pas de SMS et d'amiante dans la mise en œuvre, ce qui est incontestablement un plus pour l'écologie et l'économie de la Terre.