Introduction à l’écriture de tests avec Erlang/OTP

Spécialité(s)


Résumé

Les tests ont toujours été un facteur non négligeable de réussite lors de la conception d’un projet de développement. Ces différentes procédures, accompagnant généralement le code source, permettent d’augmenter grandement la qualité d’une application, qu’elle soit libre ou fermée. Même s’il est vrai qu’un programme ne peut-être testé dans son intégralité, les tests mis en place permettront de livrer à l’utilisateur final un système stable ainsi qu’une garantie de fonctionnement. Bien entendu, Erlang/OTP n’est pas en reste, et fournit aux développeurs tout un arsenal d’outils agissant sur l’excellence du produit.


Body

La quantité d’information et la vaste étendue de l’univers du test en informatique rendent l’écriture d’un article d’introduction ardue. Test unitaire, test fonctionnel, test de régression, test d’acceptation, test de performance, test de sécurité… sont autant de termes que l’ingénieur et le technicien ont l’habitude d’utiliser pour définir le niveau de fiabilité apporté à une application. À chaque niveau de conception, son type de test associé.

Cet article n’a pas pour ambition de rendre le lecteur expert sur un tel sujet, mais de lui faire découvrir la raison d’être de l’écriture de tests, le tout appliqué et expliqué à travers le langage Erlang. Trois de ces concepts seront d’ailleurs mis en avant dans les prochaines parties de cet article : le test unitaire permettant de tester les fonctions ; le test fonctionnel permettant de tester l’utilisation de l’application ; et l’analyse statique permettant de contrôler le bon usage des différentes parties du code. De nombreuses références à d’autres concepts ou technologies seront alors autant d’occasions pour le lecteur curieux d’enrichir sa culture et d’approfondir le sujet par lui-même.

Comme le sous-entendait l’encart d’introduction, le test de code est d’une importance cruciale pour le produit fini, mais aussi, et surtout, pour les développeurs lors de sa création. Au-delà de l’aspect qualitatif introduit par la conception de test, il s’avère aussi être un choix particulièrement intelligent et judicieux pour les programmeurs frileux ou trop peu sûrs de leurs compétences. À une époque où le syndrome de l’imposteur se trouve présent dans de nombreuses équipes, l’écriture de tests offre ici la possibilité d’expérimenter et de conceptualiser ses propres créations et idées dans un cadre rassurant, basé sur une approche ludique, voire scientifique et mathématique.

De nombreuses méthodologies ont d’ailleurs intégré la notion de test au sein même de leur définition. Le très célèbre paradigme « Agile », utilisé par de nombreuses entités publiques comme privées, n’est pas le seul à définir cette technique comme vitale pour un projet. L’« Ingénierie Logicielle en Salle Blanche », ou Cleanroom Software Engineering, modèle conçu à la fin des années 70 pour la production d’outils fiables à destination du domaine spatial ou militaire, intégrait d’ores et déjà la notion de tests statistiques. Le « cycle en V » ou encore le « modèle en cascade », de moins en moins à la mode de nos jours, ne font pas non plus exception à la règle, en définissant des cycles de tests au cours des étapes de la conception du produit.

Les méthodologies, découlant naturellement des modèles précédemment décrits, intègrent dans une large mesure ces notions. DevOps, Kanban, Scrum, ou encore eXtreme Programming, sont autant de cadres mis en places à destination des équipes qui mettent en avant les avantages de l’écriture de tests ainsi que leur maintenance sur le long terme dans un projet. L’eXtreme Programming ou XP en est d’ailleurs le meilleur exemple. Fondé à la fin des années 90 et intégralement conçu autour de la notion de TDD, il force le développeur, dès les premières lignes de code, à écrire des tests unitaires. Ces derniers permettront alors de garantir le bon fonctionnement de l’application, mais aussi de répondre à de nombreux autres besoins, tels que la facilitation de la transmission de compétences.

1. Introduction

La création d’un logiciel devrait être livrée avec les procédures nécessaires pour le tester. Ces dernières, désignées rébarbatives et ennuyeuses à produire par une grande majorité de développeurs, garantissent pourtant le bon fonctionnement de l’application sur différents systèmes et peuvent éventuellement, dans le cas de tests de régression, permettre de ne pas réintroduire des bogues ou des failles de sécurités. Malheureusement, depuis le début de cette série d’articles sur Erlang, l’auteur n’a pas été en mesure d’écrire un seul test, allant à l’encontre même de tous les principes présentés jusqu’alors. Il n’est jamais trop tard pour corriger le tir en améliorant l’existant.

Ceux qui chercheraient à trouver une excuse pour justifier un tel comportement n’en trouveraient d’ailleurs aucune. Effectivement, Erlang possède un vaste choix d’outils suffisamment simples d’utilisation pour permettre la conception de tests lors du démarrage d’un projet, et ce, sans l’aide d’aucune dépendance externe à la release livrée. La création de tests unitaires en Erlang se fait généralement via le module eunit, embarquant avec lui un certain nombre de macros facilitant la création de tests au sein d’un module.

Les tests de fonctionnalités ou tests fonctionnels se font généralement avec le module common_test et peuvent être exécutés manuellement ou automatiquement au moyen de l’exécutable ct_run. L’application Erlang Common Test est livrée avec une bibliothèque complète, aussi composée de macros et de fonctions permettant de créer des suites de tests tout en offrant la possibilité de générer des rapports, généralement compatible avec d’autres outils présents sur le marché.

Finalement, un survol rapide de l’analyse statique, dont les concepts rendront heureux plus d’un développeur, se fera au travers des outils comme Dialyzer et TypEr via la création de spécifications. Ces deux outils, tout comme EUnit et Common Test, font partie intégrante des releases livrées avec Erlang/OTP. Il est à noter que la communauté ne reste pas dépourvue d’alternatives. Des outils comme PropEr, QuickCheck ou Triq ont été conçus pour étendre les fonctionnalités des tests unitaires. D’autres outils, comme Meck ou le célèbre CutEr, permettent d’utiliser des fonctionnalités de tests avancés, comme le mocking ou le test concolic, une hybridation des tests statiques et dynamiques.

2. Test Driven Development

Parler de tests sans survoler, ou ne serait-ce qu’introduire rapidement le Développement Assisté par Tests, ou Test Driven Developpement (TDD) en anglais serait une hérésie. Créée dans les années 90 par les concepteurs de l’eXtreme Programming, cette nouvelle méthodologie donne une vision fraîche et innovante à la conception de tests.

Traditionnellement, la définition des tests se faisait toujours après la conception du code. Malheureusement, la création de ces différentes procédures n’étant pas des plus excitantes, les développeurs « oubliaient » assez fréquemment de les créer. Action qui, évidemment, pouvait engendrer un problème de maintenance sur le long terme. Une partie du code ou son intégralité n’étant plus correctement testé, entraînant la problématique de l’héritage. Le rajout de nouvelles fonctionnalités devenait alors un enfer. Encore plus lorsque les caractéristiques fonctionnelles d’origine ne devaient en aucun cas être brisées.

Le principe de base du TDD est de créer le test avant de créer le code applicatif. À l’évidence, un test sans le code applicatif est voué à l’échec. Tout le secret du TDD réside exactement au niveau de ce principe extrêmement simple. Le développeur a alors la tâche de créer un code qui valide le test mis en échec, et non plus l’inverse. Dès lors, ou bien le test est validé, et dans ce cas une autre fonctionnalité pourra être rajoutée, ou bien le test reste en erreur et le code doit alors être refactorisé. La procédure est simple : créer un nouveau test qui échoue, créer le code pour valider le test, réitérer jusqu’à ce que tous les tests soient valides.

De nombreux avantages découlent alors de ce changement drastique de philosophie. Tout d’abord, les tests sont présents de facto et la couverture du logiciel est pratiquement complète à chaque nouveau rajout de fonctions. La création de tests en premier lieu permet aussi de spécifier le code en aval au travers de son utilisation, permettant alors au développeur d’être aussi l’utilisateur de sa propre création. Finalement, la génération de tests unitaires donne aussi la possibilité d’avoir des cas d’utilisations, et donc de générer une sorte de documentation de première nécessité. Ce dernier point est d’ailleurs important, car l’écriture de la littérature technique est aussi une étape bien trop souvent méprisée par les programmeurs.

Parler de TDD sans citer le livre « Clean Code » de Robert C. Martin est pratiquement impossible. En quelques phrases, l’auteur de ce chef-d’œuvre résume tout ce que devrait être un test unitaire, et donc, comment devraient être conçus des tests avec le TDD. Cinq règles sont à respecter, portant l’acronyme de FIRST en anglais (Fast, Independent, Repeatable, Self-Validating et Timely) :

  • Rapide : les tests devraient être rapides et ils devraient être exécutés rapidement. Quand les tests sont lents, le développeur ne veut ou ne peut pas les lancer fréquemment. S’ils ne sont pas lancés à intervalle régulier, les problèmes ne seront pas trouvés rapidement et ne seront pas corrigés facilement. Le développeur ne se sentira pas libre de nettoyer le code. Puis, éventuellement, dans le pire des cas, le code commencera à pourrir.
  • Indépendant : les tests devraient être indépendants les uns des autres et ne devraient pas dépendre d’autres tests. Un test ne devrait pas être la condition pour démarrer un autre test. Quand des tests sont dépendants d’autres, le premier qui échoue génère une cascade d’échecs rendant le diagnostic difficile, pouvant alors cacher des malfonctions en amont.
  • Reproductible : les tests devraient être reproductibles sur tout type d’environnement. Le développeur doit être capable de les lancer en environnement de production, dans les environnements de qualification et sur son propre ordinateur lorsqu’il n’a accès à aucun réseau. Si les tests ne sont pas reproductibles, alors le développeur aura toujours une excuse pour la raison de l’échec. Il sera aussi bloqué si l’environnement n’est pas disponible lors du lancement des tests.
  • Arbitraire : les tests devraient retourner une valeur booléenne. Soit ils réussissent, soit ils échouent. Le fichier de log ne devrait pas être lu pour dire si le test est passé ou non. Le développeur ne devrait pas avoir à comparer de fichier manuellement pour savoir si le test est passé ou non. Si le test n’est pas arbitraire, l’échec peut alors devenir subjectif et demandera une longue évaluation manuelle pour sa correction.
  • Opportun : les tests devraient être conçus au bon moment. Les tests unitaires devraient être écrits juste avant le code de production. Si les tests sont écrits après le code de production, alors le développeur trouvera que ce code est trop compliqué à tester. Donc, il ne pourra pas concevoir un code de production testable.

3. Tests unitaires avec EUnit

EUnit [1] est un framework de tests unitaires conçu par Richard Carlsson et livré par défaut avec chaque release d’Erlang/OTP depuis 2008. Il permet de faciliter la conception de tests unitaires au moyen de macros présents dans l’en-tête fournie avec l’application homonyme. Sachant que l’en-tête peut-être ajouté à volonté dans n’importe quel module, il est possible d’intégrer eunit soit dans un code déjà existant, ou de créer un module séparément contenant les tests comme le code présenté ci-dessous :

-module(mon_module).
-include_lib("eunit/include/eunit.hrl").

Les tests sont alors regroupés au sein de fonctions portant un nom libre, mais se terminant obligatoirement par le terme _test. Lors de la compilation, EUnit les exportera automatiquement et s’occupera de créer les autres prérequis nécessaires à l’exécution des tests unitaires, tels que la fonction mon_module:test/0, permettant l’exécution en parallèle de tous les tests définis et présents dans le module mon_module.

L’écriture des tests avec EUnit se fait donc via l’utilisation des macros fournies. Ces dernières sont des termes interprétés et remplacés par le préprocesseur avant la compilation finale. Le remplacement se fait par un modèle de code généré en amont et permettra de créer les éléments du module final. Les macros disponibles avec EUnit permettent généralement de créer des « affirmations » ou « assertion » en anglais. Le développeur, lors de l’écriture de tests, « affirme » une vérité sur une fonction en définissant le retour attendu de celle-ci basé sur les arguments passés en argument. Aucune surprise ici, les fonctionnalités présentent sont similaires à celles fournies par bien d’autres langages disponibles sur le marché.

L’une des premières macros à connaître est ?assert/1. Elle va contrôler le retour d’une fonction booléenne pour s’assurer que le test est validé en fonction des paramètres qui lui sont passés. Dans l’exemple ci-dessous, le premier test affirme que le nombre 1 est bien égal à 1. Le second, que l’addition de 1 et 1 est bien égale à 2. Sauf exception particulière, la dernière affirmation créée est volontairement fausse et affirme que l’addition de 2 et 2 doit être égale à 5. Cette erreur permettra de montrer le résultat d’un test qui échoue dans la suite de l’article.

assert_test() ->
   ?assert(1 == 1),
   ?assert(1 + 1 == 2),
   ?assert(2 + 2 == 5).

La seconde macro présentée n’est autre que ?assertEqual/2. Elle exécute le contenu du deuxième argument et compare son résultat avec le premier argument. Les deux doivent être identiques. Le premier exemple ci-dessous affirme que l’atome test est bien équivalent à un autre atome test, le second affirme que l’addition de 1 et 1 est bien égale à 2.

equal_test() ->
   ?assertEqual(test, test),
   ?assertEqual(2, erlang:'+'(1, 1)).

La troisième macro mise en avant est ?assertNotEqual/2, qui est la négation de la macro ?assertEqual/2, et fonctionne donc sur la même logique. Elle permet d’affirmer qu’une valeur n’est pas égale à une autre. Ici, le premier exemple affirme qu’une liste vide ([]) n’est pas égale à une bitstring vide (<<>>) et que la division de 1 par 2 n’est pas égale à 3.

not_equal_test() ->
   ?assertNotEqual([], <<>>),
   ?assertNotEqual(3, erlang:'/'(1, 2)).

Même s’il est nécessaire de tester des affirmations classiques, il est parfois aussi nécessaire de tester le retour des erreurs et des exceptions. Effectivement, lors de la conception de l’application, il est possible que le programme remonte une exception lors d’une erreur critique, telle qu’une division par zéro, ou que le développeur génère explicitement une exception via erlang:throw/1 pour signaler un comportement attendu spécifique. C’est ici qu’intervient la macro ?assertException/3, dans les exemples suivants, l’addition d’un atome avec un nombre génère une erreur badarith, l’utilisation de la fonction erlang:throw/1 génère une exception de type throw tout en ayant une valeur définie par le développeur (disabled). Pour finir, le dernier exemple montre l’utilisation de la fonction erlang:exit/1 qui génère une exception de type exit et retournant l’atome normal comme valeur de sortie, le tout via une fonction lambda.

exception_test() ->
   ?assertException(error, badarith, erlang:'+'(1, a)),
   ?assertException(throw, disabled, erlang:throw(disabled)),
   ?assertException(exit, normal, (fun() -> erlang:exit(normal) end)()).

Évidemment, les tests peuvent inclure des messages de debug définis par son créateur et créés au moyen de la macro ?debugMsg/1. Encore une fois, très peu de surprises ici, l’exécution d’une telle macro affichera le message précédé de différentes informations, comme le nom du fichier, la référence à la ligne de code ainsi que l’identifiant du processus ayant généré ce message.

debug_test() -> ?debugMsg(test).

Lors de la compilation, les tests pourront alors être exécutés individuellement ou via la fonction autogénérée mon_module:test/0. En voici l’exemple au travers du shell Erlang :

1> c(mon_module).
{ok, mon_module}.
2> mon_module:assert_test().
** exception error: {assert,[{module,mon_module},
                             {line,7},
                             {expression,"2 + 2 == 5"},
                             {expected,true},
                             {value,false}]}
     in function mon_module:'-assert_test/0-fun-2-'/0 (/home/user/mon_module.erl, line 7)
3> mon_module:equal_test()
.ok
4> mon_module:not_equal_test()
.ok
5> mon_module:test().
mon_module: assert_test...*failed*
in function mon_module:'-assert_test/0-fun-2-'/0 (/home/user/mon_module.erl, line 7)
in call from eunit_test:'-mf_wrapper/2-fun-0-'/2 (eunit_test.erl, line 273)
in call from eunit_test:run_testfun/1 (eunit_test.erl, line 71)
in call from eunit_proc:run_test/1 (eunit_proc.erl, line 510)
in call from eunit_proc:with_timeout/3 (eunit_proc.erl, line 335)
in call from eunit_proc:handle_test/2 (eunit_proc.erl, line 493)
in call from eunit_proc:tests_inorder/3 (eunit_proc.erl, line 435)
in call from eunit_proc:with_timeout/3 (eunit_proc.erl, line 325)
**error:{assert,[{module,mon_module},
         {line,7},
         {expression,"2 + 2 == 5"},
         {expected,true},
         {value,false}]}
  output:<<"">>
 
=======================================================
  Failed: 1. Skipped: 0. Passed: 3.
error

Le test qui échoue informe le développeur de la fonction exécutée, du retour attendu et de la valeur reçue lors de l’exécution. Après correction de la fonction mon_module:assert_test/0 avec une affirmation fonctionnelle, voici ce que devrait imprimer la fonction mon_module:test/0 :

6> mon_module:test().
  All 4 tests passed.
ok

4. Tests fonctionnels avec Common Test

Common Test [2] est un framework de tests extrêmement complet. L’application en elle-même est apparue en 2003 et permet de créer des suites de tests regroupés au sein de plusieurs modules nommés « suites ». Il permet entre autres d’utiliser des concepts comme les tests en boite noire (sans connaissance du code source) ou les tests en boite blanche (avec connaissance du code source). Tout comme EUnit, Common Test est livré avec des exécutables, des bibliothèques et des en-têtes permettant de faciliter la création de tests. Ces différents outils vont même encore plus loin en permettant de créer des rapports ou de faire des analyses de couverture. La création d’une suite de tests avec Common Test se fait généralement en créant un fichier avec un nom libre obligatoirement suivi du terme _SUITE.

$ touch ma_SUITE.erl

Ce fichier va contenir alors une structure standardisée basée sur des callbacks, comme cela a pu être aperçu lors de l’étude des behaviours. L’en-tête est rajouté à la suite, généralement stocké dans le chemin relatif common_test/_include/ct.hrl. Étant donné que ce n’est pas un code applicatif, et par simplicité, le rajout du drapeau de compilation export_all permet d’exporter automatiquement toutes les fonctions.

-module(ma_SUITE).
-compile(export_all).
-include_lib("common_test/include/ct.hrl").

La suite de test peut recevoir une configuration particulière au travers du callback ma_SUITE:suite/0 permettant, entre autres, de configurer les durées d’exécution ou les divers prérequis. Une liste vide peut être retournée si aucune configuration n’est à faire.

suite() -> [].

Les tests au sein de la suite peuvent être groupés dans des groupes ayant un nom et des particularités bien précises définies au travers du callback ma_SUITE:groups/0. Similaire à la fonction précédente, si aucun groupe n’est configuré, cette fonction peut être omise ou retourner une liste vide.

groups() -> [].

Vient alors la partie d’initialisation et de nettoyage de chaque test. Une suite de tests peut posséder une configuration particulière qui sera partagée tout au long de l’exécution de la suite. Cette configuration est initialisée au moyen du callback ma_SUITE:init_per_suite/1 et terminée via le callback ma_SUITE:end_per_suite/1. À noter que la configuration partagée est normalement une proplist.

init_per_suite(Config) -> Config.
end_per_suite(Config) -> ok.

Un groupe de tests peut recevoir sa propre configuration, fonctionnement sur le même principe que l’initialisation de la suite de tests. Cette partie est gérée au travers des callbacks ma_SUITE:init_per_group/2 et ma_SUITE:en_per_group/2. Le premier argument fait référence au nom du groupe, et le second, à la configuration retournée par init_per_suite/1.

init_per_group(Groupe, Config) -> Config.
end_per_group(Groupe, Config) -> ok.

Finalement, après avoir initialisé la suite de tests puis les groupes, la dernière étape possible est l’initialisation des tests eux-mêmes. Cette partie se fait au moyen des callbacks ma_SUITE:init_per_testcase/2 et ma_SUITE:end_per_testcase/2. Le premier argument correspond au nom du test et le second n’est autre que la configuration qui aura été retournée soit par init_per_suite/1 ou init_per_group/2.

init_per_testcase(Test, Config) -> Config.
end_per_testcase(Test, Config) -> Config.

Il ne reste alors plus qu’à définir le test. Dans cet exemple, il s’appelle ma_SUITE:mon_test/1. Le premier argument fait référence à la configuration générée par les fonctions d’initialisation.

mon_test(Config) -> ok == ok.

La dernière étape est de créer le callback ma_SUITE:all/0 qui contiendra la liste de tous les tests à exécuter, les groupes et autres propriétés définies dans la documentation officielle.

all() -> [mon_test].

Maintenant que la suite de tests est au complet, la commande ct_run peut alors être utilisée au travers d’un shell classique, ou via le shell Erlang. Ci-après, son utilisation avec un shell classique :

$ ct_run -dir .
Common Test v1.16.1 starting (cwd is /home/user/test)
 
Eshell V10.2 (abort with ^G)
Common Test: Running make in test directories...
CWD set to: "/home/user/test/ct_run.ct@kin.2020-04-21_09.10.46"
Testing user.tmp: Starting test, 1 test cases
Testing user.tmp: TEST COMPLETE, 1 ok, 0 failed of 1 test cases
Updating /home/user/test/index.html ...
done
Updating /home/user/test/all_runs.html ...
done
TEST INFO: 1 test(s), 1 case(s) in 1 suite(s)

Cette commande lance la suite de tests et génère un rapport au format HTML dans le répertoire d’exécution. Si l’application testée utilise les sorties standard, au travers d’io:format/2 par exemple, ces messages seront automatiquement stockés dans le fichier de log et le rapport final.

5. Types, spécifications et analyse statique

Les tests unitaires et les tests fonctionnels permettent d’offrir un cadre au développeur, mais ne garantissent en aucun cas l’exactitude du programme conçu. Ils permettent généralement de s’assurer de l’aspect fonctionnel des différentes briques définies par le concepteur, mais ne vont pas vérifier si le contenu du code interagit correctement avec les différents modules ou fonctions. C’est à ce moment précis qu’intervient l’analyse statique.

Comme vu lors des derniers articles, Erlang est un langage de haut niveau, dynamiquement typé, utilisant le paradigme fonctionnel. D’autres langages de cette famille, tels qu’Haskell ou encore OCaml, permettent de spécifier les fonctions. Une spécification peut être vue comme une sorte de contrat entre la fonction et le développeur. Elle permet d’avoir une vision abstraite de cette dernière en définissant le type de données attendues en entrée et en sortie. En ayant à disposition ces deux informations, la spécification et le contenu de la fonction, le compilateur ou un outil d’analyse externe ont le pouvoir de savoir si le développeur n’a pas généré un code erroné.

L’écriture de spécifications [5] en Erlang est une tâche triviale, utilisant une fois de plus le système de préprocesseur. Une spécification précède généralement la déclaration d’une fonction. Le code suivant crée deux fonctions assez simples, la première prend un nombre en argument et l’incrémente. La seconde réutilise cette fonction, en lui passant une chaîne de caractères.

-module(ma_spec).
-export([ma_fonction/1, usage/0]).
 
-spec ma_fonction(Argument) -> Retour when
   Argument :: number() | atom(),
   Retour :: number() | {error, not_supported}.
ma_fonction(Argument)
   when is_number(Argument) ->
      Argument + 1;
ma_fonction(Argument)
   when is_atom(Argument) ->
      {error, not_supported}.
 
-spec usage() -> number().
usage() ->
    ma_fonction("test").

Un tel code peut se trouver plus facilement que le lecteur pourrait le penser. Il arrive en effet que certains programmes soient assez complexes, et que le développeur fasse une erreur lors du passage d’un argument. C’est donc à cette étape du code qu’il est possible d’utiliser dialyzer. Arrivé à un moment dans la phase de développement où la complexité devient trop grande, il est nécessaire de valider si chacune des fonctions reçoit bien le bon type de données ou si certaines variables n’auraient pas été oubliées.

Les applications comme Dialyzer [3] ou TypEr [4] apparaissent ici pour valider si les contrats sont bien respectés lors de l’appel des différentes fonctions créées par le développeur. Dialyzer peut-être utilisé de plusieurs façons, soit via la ligne de commande, ou bien directement via le shell Erlang. Il a entre autres besoin d’avoir accès aux fichiers binaires compilés avec les options de debug (via le flag debug_info).

$ erlc +debug_info ma_spec.erl

Le fichier généré devrait se nommer ma_spec.beam. Ce fichier peut maintenant être analysé par Dialyzer. Ce dernier va tout d’abord créer une base de données contenant les informations nécessaires à l’analyse ou Persistent Lookup Table (PLT). Par défaut, ce document est enregistré dans l’espace de l’utilisateur. Puis, dialyzer va comparer le contenu des spécifications avec l’utilisation des fonctions présentes dans le code.

$ dialyzer ma_spec.beam
   Checking whether the PLT /home/user/.dialyzer21_plt is up-to-date... yes
   Proceeding with analysis...
ma_spec.erl:18: Function usage/0 has no local return
ma_spec.erl:19: The call ma_spec:ma_fonction([101 | 115 | 116,...]) will never return since the success typing is (atom() | number()) -> number() | {'error','not_supported'} and the contract is (Argument) -> Retour when Argument :: number() | atom(), Retour :: number() | {'error','not_supported'}
Unknown functions:
  erlang:get_module_info/1
  erlang:get_module_info/2
done in 0m0.13s
done (warnings were emitted)

Dialyzer note que la fonction ma_spec:usage/0 ne retourne pas de données explicitement, mais montre aussi qu’il y a un problème entre la spécification de ma_spec:ma_fonction/1 et son utilisation à la ligne 19. Les dernières erreurs listées font référence à des fonctions non présentes dans le fichier PLT. Ce fichier devrait regrouper normalement toutes les fonctions utilisées par le programme à analyser, comme les bibliothèques standard ou encore le kernel.

6. Intégration de tests

L’application cache a d’ores et déjà fortement évolué depuis le premier article de cette longue série [7]. Passant d’un simple bout de code non standardisé à un projet géré par Rebar3. De nouvelles briques sont maintenant à rajouter, le support de tests, qu’ils soient unitaires ou fonctionnels, mais aussi les spécifications.

6.1 Intégration d’EUnit

La création de tests unitaires peut se faire de plusieurs façons, il n’y a pas réellement de règle. Dans le cas d’une petite bibliothèque, ou d’une preuve de concept, les tests peuvent être directement intégrés dans le code. Étant donné que le code applicatif et le code de test sont écrits en même temps, le fait de mettre le code au même endroit permet de faciliter l’édition.

Par ailleurs, sur le long terme, le rajout de tests unitaires au même endroit peut rendre le code illisible et compliqué à maintenir. La création d’un fichier séparé pourra alors faire partie de la solution adoptée. Attention tout de même, la création d’un fichier externe ne donne pas la possibilité de tester les fonctions internes et non exportées d’un module, même s’il est rare d’avoir à le faire, certains de ces tests sont parfois nécessaires.

$ cd cache
$ mkdir test
$ touch test/cache_test.erl

Une suite de tests unitaires est un module Erlang classique. L’inclusion de l’en-tête eunit.hrl permet de donner accès aux macros et fonctionnalités d’eunit, ce qui, par exemple, évite de rajouter les fonctions à exporter manuellement, eunit s’en chargeant automatiquement.

-module(cache_test).
-include_lib("eunit/include/eunit.hrl").

Habituellement, toutes les fonctions ne sont pas nécessaires à tester, mais dans le cadre de cet exemple, chaque partie sera testée. En faisant ainsi, cela permet aussi de montrer la flexibilité incroyable du système de behaviour, qui permet de créer des tests pour chaque callback, sans avoir à démarrer le processus. Le premier test fait référence au callback cache:init/1 et valide la structure de données partagée comme état au processus démarré.

init_test() -> ?assertEqual({ok, #{}}, cache:init([])).

Le second test fait référence au rajout d’une clé au sein d’un process. Pour ce faire, le callback cache:handle_cast/2 est utilisé. Le premier argument passé est un tuple contenant la clé et la valeur.

add_key_test() ->
    {ok, Etat} = cache:init([]),
    ?assertEqual({noreply, Etat#{ cle => valeur}}, cache:handle_cast({add, cle, valeur}, Etat)).

Tout comme le test précédent, la suppression d’une clé se fait via un appel asynchrone en utilisant le callback cache:handle_cast/2.

delete_key_test() ->
    {ok, Etat} = cache:init([]),
    ?assertEqual({noreply, #{}}, cache:handle_cast({delete, cle}, Etat)),
    cache:handle_cast({add, cle, valeur}, Etat),
    ?assertEqual({noreply, #{}}, cache:handle_cast({delete, cle}, Etat)).

Trois fonctions ont été créées pour récupérer les données stockées et utilisent des appels synchrones au travers du callback cache:handle_call/3. La première fonction testée permet de lister la liste des clés présentes dans la structure de donnée.

get_keys_test() ->
    {ok, Etat} = cache:init([]),
    ?assertEqual({reply, [], Etat}, cache:handle_call(get_keys, undefined, Etat)),
    {noreply, Etat2} = cache:handle_cast({add, cle, value}, Etat),
    ?assertEqual({reply, [cle], Etat2}, cache:handle_call(get_keys, undefined, Etat2)).

Seconde méthode, la récupération de la liste des valeurs stockées dans la structure donnée.

get_values_test() ->
    {ok, Etat} = cache:init([]),
    ?assertEqual({reply, [], Etat}, cache:handle_call(get_values, undefined, Etat)),
    {noreply, Etat2} = cache:handle_cast({add, cle, value}, Etat),
    ?assertEqual({reply, [value], Etat2}, cache:handle_call(get_values, undefined, Etat2)).

Finalement, la dernière fonctionnalité à tester est la récupération d’une valeur via une clé.

get_key_test() ->
    {ok, Etat} = cache:init([]),
    ?assertEqual({reply, undefined, Etat}, cache:handle_call({get, cle}, undefined, Etat)),
    {noreply, Etat2} = cache:handle_cast({add, cle, value}, Etat),
    ?assertEqual({reply, value, Etat2}, cache:handle_call({get, cle}, undefined, Etat2)).

Après avoir enregistré le fichier, il est alors possible d’utiliser rebar3 pour exécuter les tests unitaires via la ligne de commande au moyen de sa sous-commande eunit.

$ rebar3 eunit
===> Verifying dependencies...
===> Compiling cache
===> Performing EUnit tests...
......
Top 6 slowest tests (0.000 seconds, 0.0% of total time):
  cache_test:init_test/0
    0.000 seconds
  cache_test:get_values_test/0
    0.000 seconds
  cache_test:delete_key_test/0
    0.000 seconds
  cache_test:add_key_test/0
    0.000 seconds
  cache_test:get_keys_test/0
    0.000 seconds
  cache_test:get_key_test/0
    0.000 seconds
Finished in 0.224 seconds
6 tests, 0 failures

Les six tests précédemment testés sont exécutés en parallèle et ne retournent aucune erreur. Il est maintenant possible de passer à l’étape suivante : les tests fonctionnels.

6.2 Intégration de Common Test

Les tests fonctionnels créés avec Common Test sont généralement stockés dans le sous-répertoire test du projet, tout comme les tests unitaires avec EUnit. La seule différence est leur nom, se terminant par _SUITE.erl.

$ cd cache
$ touch test/cache_SUITE.erl

Le module est défini par l’en-tête suivant, il définit les fonctions qui seront utilisées, mais aussi le chemin de l’en-tête de common_test : common_test/include/ct.hrl.

-module(cache_SUITE).
-export([all/0, suite/0]).
-export([init_per_suite/1, end_per_suite/1]).
-export([init_per_testcase/2, end_per_testcase/2]).
-export([cache/1, cache_sup/1, cache_app/1]).
-include_lib("common_test/include/ct.hrl").

Le callback cache_SUITE:all/0 retourne la liste des tests, dans cet exemple, les modules cache, cache_sup et cache_app seront testés séparément.

all() ->
    [cache, cache_sup, cache_app].

Le callback cache_SUITE:suite/0 permet de paramétrer le comportement de common_test, en configurant, par exemple, le temps limite d’exécution, ou les prérequis. Ces fonctionnalités n’ont actuellement pas de raison d’être, et une liste vide est retournée.

suite() -> [].

Le callback cache_SUITE:init_per_suite/1 permet de configurer l’environnement lors de l’exécution des tests. Cette configuration est partagée par tous les tests. Ici, aucun intérêt de rajouter une telle configuration.

init_per_suite(_Config) -> _Config.

Le callback cache_SUITE:end_per_suite/1 permet de nettoyer l’environnement après le déroulement des tests. Vu qu’aucune initialisation n’a été réalisée, aucune action n’est nécessaire.

end_per_suite(_Config) -> ok.

Le callback cache_SUITE:init_per_testcase/2 permet d’initialiser l’environnement en fonction d’un scénario de test. Trois scénarios sont actuellement définis, cache, cache_sup et cache_app, qui seront alors autant de définitions créées. Dans le cas du scénario cache, le module est démarré manuellement, l’identifiant du processus est stocké dans une proplist. Le même procédé est utilisé pour cache_sup qui est alors démarré manuellement, son identifiant stocké dans une proplist. Dans le cas de cache_app, l’application est démarrée et aucune configuration n’est retournée.

init_per_testcase(cache, Config) ->
    {ok, Pid} = cache:start_link(),
    [{pid,Pid}|Config];
init_per_testcase(cache_sup, Config) ->
    {ok, Pid} = cache_sup:start_link(),
    [{pid,Pid}|Config];
init_per_testcase(cache_app, Config) ->
    ok = application:start(cache),
    Config.

Tout comme une fonction d’initialisation est créée, une fonction de nettoyage par scénario de test pourra exister. Généralement, les fonctions classiques d’arrêts sont à utiliser, comme gen_server:stop/1 pour l’arrêt des processus cache ou cache_sup, mais aussi application:stop/1 pour l’application cache.

end_per_testcase(cache, Config) ->
    Pid = proplists:get_value(pid, Config),
    gen_server:stop(Pid);
end_per_testcase(cache_sup, Config) ->
    Pid = proplists:get_value(pid, Config),
    gen_server:stop(Pid);
end_per_testcase(cache_app, _Config) ->
    application:stop(cache).

Après avoir configuré l’environnement où les tests seront exécutés, il est temps de définir les scénarios. Le premier à être conçu est cache_SUITE:cache/1. Le processus cache démarré via cache_SUITE:init_per_testcase/2 est récupéré au travers de la configuration sous forme de proplist et stockée dans la variable Cache. Le scénario est explicite : rajout d’une clé, validation du retour des fonctions cache:get_values/1, cache:get_keys/1, cache:get/2 et cache:delete/2.

cache(Config) ->
    Cache = proplists:get_value(pid, Config),
    ok = cache:add(Cache, cle, valeur),
    [cle] = cache:get_keys(Cache),
    [valeur] = cache:get_values(Cache),
    valeur = cache:get(Cache, cle),
    ok = cache:delete(Cache, cle),
    [] = cache:get_keys(Cache),
    [] = cache:get_values(Cache),
    undefined = cache:get(Cache, cle).

Le scénario pour tester la supervision est défini au travers de la fonction cache_SUITE:cache_sup/1. Tout comme le scénario précédent, l’identifiant du processus de supervision est stocké et récupéré dans la configuration au moyen de la fonction proplists:get_value/2 et stocké dans la variable CacheSup. Les tests sont, une fois de plus, explicites. Ils valident la sortie attendue des fonctions fournies par le superviseur cache_sup, tels que le nombre de processus actif, le nombre de spécifications de processus enfant ainsi que les sous-processus démarrés et gérés par le superviseur. La dernière partie du scénario permet aussi de tester si le processus cache fonctionne correctement dans ce cadre d’utilisation.

cache_sup(Config) ->
    CacheSup = proplists:get_value(pid, Config),
    Count = supervisor:count_children(CacheSup),
    1 = proplists:get_value(specs, Count),
    1 = proplists:get_value(active, Count),
    0 = proplists:get_value(supervisors, Count),
    1 = proplists:get_value(workers, Count),
    [{cache, Cache, worker, [gen_server]}] = supervisor:which_children(CacheSup),
    cache([{pid, Cache}]).

Maintenant que le test du processus cache et celui du superviseur ont été créés, il reste à concevoir celui de l’application via cache_SUITE:cache_app/1. Cette dernière a été démarrée lors de l’initialisation de l’environnement. Les fonctions utilisées dans le cadre de ce scénario s’assurent que l’application est bien visible, et que les fonctionnalités sont respectées en réutilisant les deux scénarios précédemment créés.

cache_app(_Config) ->
    ok = application:ensure_started(cache),
    CacheSup = erlang:whereis(cache_sup),
    true = erlang:is_pid(CacheSup),
    cache_sup([{pid, CacheSup}]),
    Cache = erlang:whereis(cache),
    true = erlang:is_pid(Cache),
    cache([{pid, Cache}]).

Il est à noter que la réutilisation de tests dans d’autres tests est une mauvaise pratique, comme vu dans la partie sur le TDD. Le code présent ici est à but éducatif et permet de réduire l’espace sur l’article. Il aurait peut-être été préférable de créer manuellement les mêmes tests pour les différents modes, ou simplement laisser le test du module principal.

L’exécution des tests peut se faire au travers de rebar3 et de sa sous-commande ct. Les suites de tests sont alors exécutées et retournent leur état.

$ rebar3 ct
===> Verifying dependencies...
===> Compiling cache
===> Running Common Test suites...
%%% cache_SUITE: ...
All 3 tests passed.

Les tests fonctionnels ne seraient pas complets sans la génération d’un rapport. Celui-ci se retrouve dans le sous-répertoire _build/test/logs et contient tous les comptes rendus d’exécution au format HTML. Leur lecture peut se faire avec le navigateur web préféré du testeur :

$ firefox _build/test/logs/all_runs.html

6.3 Ajout de spécifications

En principe, la création des spécifications se fait avant ou en même temps que la création de la fonction. Une spécification permet de conceptualiser et de réfléchir aux types de données qui vont transiter par cette dernière. Comme vu dans les précédentes parties de cet article, Dialyzer et TypEr en ont besoin pour permettre de valider l’utilisation correcte du code.

La première spécification présentée est cache:start_link/0.

-spec start_link() -> {ok, Pid} when
      Pid :: pid().

La spécification de la fonction cache:init/1 récupère n’importe quel type de données via le type term() et retourne un tuple contenant un atome ok et une structure de données map().

-spec init(Args) -> Resultat when
      Args :: term(),
      Resultat :: {ok, map()}.

La spécification de la fonction cache:terminate/2 attend deux arguments, le premier, la raison de l’arrêt est d’un type indéfini, l’état du processus doit être une structure de données map(). La fonction retourne toujours l’atome ok.

-spec terminate(Raison, Etat) -> Resultat when
      Raison :: term(),
      Etat :: map(),
      Resultat :: ok.

La spécification du callback cache:handle_cast/2 est légèrement plus complexe. Elle permet de recevoir plusieurs types de messages sous forme de tuples. L’état est forcement une structure de donnée map() et elle retourne un tuple contenant l’atome noreply et l’état.

-spec handle_cast(Message, Etat) -> Resultat when
      Message :: {add, term(), term()} |
                 {delete, term()},
      Etat :: map(),
      Resultat :: {noreply, map()}.

La spécification du callback cache:handle_call/3 est similaire à la précédente, l’exemple suivant y définit le type de message attendu, ainsi que le type de données utilisé par l’état du processus.

-spec handle_call(Message, From, Etat) -> Resultat when
      Message :: get_keys | get_values | {get, term()},
      From :: {pid(), term()},
      Etat :: map(),
      Resultat :: {reply, term(), Etat}.

Les fonctions cache:add/3, cache:delete/2, cache:get_keys/1, cache:get_values/1 et cache:get/2 font partie des interfaces utilisées par les autres parties du code, et doivent aussi être spécifiées, ce qui permettra d’évaluer les fonctions les utilisant.

-spec add(Pid, Cle, Valeur) -> Resultat when
      Pid :: pid() | atom(),
      Cle :: term(),
      Valeur :: term(),
      Resultat :: ok.
-spec delete(Pid, Cle) -> Resultat when
      Pid :: pid() | atom(),
      Cle :: term(),
      Resultat :: ok.
-spec get_keys(Pid) -> Resultat when
      Pid :: pid() | atom(),
      Resultat :: [] | [term()].
-spec get_values(Pid) -> Resultat when
      Pid :: pid() | atom(),
      Resultat :: [] | [term()].
-spec get(Pid, Cle) -> Resultat when
      Pid :: pid() | atom(),
      Cle :: term(),
      Resultat :: undefined | term().

Pour éviter de faire toutes les étapes manuellement via la ligne de commande, rebar3 fournit le support de dialyzer (et de bien d’autres fonctionnalités) nativement.

$ rebar3 dialyzer
===> Verifying dependencies...
===> Compiling cache
===> Dialyzer starting, this may take a while...
===> Updating plt...
===> Resolving files...
===> Updating base plt...
===> Resolving files...
===> Checking 191 files in "/home/user/.cache/rebar3/rebar3_21.2_plt"...
===> Copying "/home/user/.cache/rebar3/rebar3_21.2_plt" to "cache/_build/default/rebar3_21.2_plt"...
===> Checking 191 files in "cache/_build/default/rebar3_21.2_plt"...
===> Doing success typing analysis...
===> Resolving files...
===> Analyzing 4 files with "cache/_build/default/rebar3_21.2_plt"...

L’analyse n’a retourné aucune erreur. Les fonctions respectent les différents contrats au travers de spécifications créées. Évidemment, l’exemple de l’application cache étant assez simple, sans grande interaction avec d’autres applications, le risque de trouver une erreur à ce niveau de développement est encore faible.

Conclusion

C’est un fait indéniable, dont les chiffres montrent l’étendue du risque : sur 1000 lignes de code produites par un développeur dans le monde de l’industrie, entre 15 et 50 erreurs parfois critiques sont présentes. Sans méthodologie ou technique particulière, ces chiffres ne peuvent qu’augmenter, pouvant causer de lourdes pertes financières, voire humaines. Il a pourtant été prouvé que la mise en place de solutions basées sur la revue de code ou la simple écriture de tests permet de faire descendre ces chiffres en dessous des 3 erreurs pour 1000 lignes de codes.

Un langage prônant la haute disponibilité et la qualité ne peut pas passer à côté de ces données. Il se doit alors d’offrir à ses utilisateurs la possibilité de créer facilement un code robuste, dans un environnement contrôlé. Un lieu où les doutes du concepteur sont mis au second plan, tout en prêchant une approche résolument scientifique pour la création d’applications innovantes.

Erlang/OTP permet à tout développeur de maintenir aisément des suites complètes de tests, de créer facilement des tests unitaires et offre, de surcroît, la possibilité d’analyser son code sans avoir besoin de dépendances externes. De plus, la communauté ne reste pas muette, et fournit bien d’autres outils permettant de combler les éventuelles lacunes de tous ces outils.

Quelle conclusion après un tel article ? Un test se doit d’être simple à créer, à lire et à utiliser. Il permet d’offrir une documentation rustique, mais efficace. Écrivez des tests. Pour votre propre bien et celui des autres. Mais surtout, n’oubliez jamais la philosophie KISS : « Keep it simple. Stupid. »

Références

[1] Documentation d’EUnit : https://erlang.org/doc/apps/eunit

[2] Documentation de Common Test : https://erlang.org/doc/apps/common_test

[3] Documentation de TypEr : https://erlang.org/doc/man/typer.html

[4] Documentation de Dialyzer : https://erlang.org/doc/apps/dialyzer

[5] Documentation des types et spécifications : https://erlang.org/doc/reference_manual/typespec.html

[6] Code source des exemples : https://github.com/niamtokik/linux-mag

[7] M. KERJOUAN, « Erlang, programmation distribuée et modèle acteur  », GNU/Linux Magazine no237, mai 2020 : https://connect.ed-diamond.com/GNU-Linux-Magazine/GLMF-237/Erlang-programmation-distribuee-et-modele-acteur

Pour aller plus loin

La référence reste la documentation officielle qui est d’ores et déjà bien fournie. De nombreux exemples et modèles y sont présents, permettant ainsi au lecteur de comprendre comment fonctionnent les multiples outils ou frameworks livrés avec une release d’Erlang. Par ailleurs, le livre « Property-Based Testing with Proper, Erlang and Elixir » écrit par Fred Hebert étend le sujet au-delà du simple test unitaire en faisant découvrir au lecteur le test de propriété ou quickcheck, technique originellement conçue pour le langage Haskell et consistant à générer automatiquement des suites de tests. Cet outil est incroyablement performant et utile pour assister le développeur dans sa lourde tâche de concepteur d’application.



Article rédigé par

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous