Comment écrire un framework de test pour Ruby
Si vous souhaitez comprendre le fonctionnement d’un framework de test, si vous voulez écrire votre propre framework et que vous ressentiez le besoin d’un coup de pouce, cet article est fait pour vous. Nous verrons étape par étape comment écrire une telle chose. Le framework que nous réaliserons tiendra en quelques dizaines de lignes de Ruby et pourra être utilisé comme une base pour vos futures réalisations.
Par où commencer ?
Pour ce genre de problématique, je trouve qu’il est plus simple de partir d’un exemple concret du langage, de l’API, que l’on souhaite obtenir. Dans notre cas, nous pouvons déjà écrire quelques tests même si nous ne pouvons pas encore les faire tourner.
Voici 3 tests pour une classe Rover. Classe qui aura une position x,y et une direction :
J’ai cherché à faire au plus simple. Tout se passe à l’intérieur d’une classe.
Ça nous permettra d’hériter facilement de certains comportements, comme la
méthode assert
qui sera définit dans la classe Tasty::Unit
.
Chaque méthode qui commence par test_
représente un test, et assert
se
contente de vérifier si son argument est vrai ou faux.
On lancera le programme avec le nom d’un fichier de test, par exemple
tasty test_rover.rb
. Mais par souci de simplicité, nous nous contenterons
d’utiliser directement l’interpréteur Ruby de cette manière :
ruby tasty.rb test_rover.rb
.
Voici ce que j’imagine en terme d’affichage :
$ ruby tasty.rb test_rover.rb
ok - test_it_has_a_position
not ok - test_it_has_a_given_direction
<< ERROR REPORT GOES HERE >>
ok - test_it_has_a_direction_by_default
Le nom d’un test est précédé de “ok” si il a réussi, ou de “not ok” si il a échoué. Le rapport d’erreur est affiché aussitôt après une ligne “not ok”.
Retrouver la classe de test
Commençons par le plus simple, définissons une classe Rover dans un fichier
rover.rb
:
Puis définissons notre premier test, dans un fichier test_rover.rb
. Ce
premier test va nous guider pendant un bout de temps :
Maintenant, dans un fichier tasty.rb
, définissons le namespace Tasty et une
classe principale. Nous initialiserons cette classe avec le nom de fichier
passé en argument sur la ligne de commande. Nous afficherons un message
temporaire pour nous assurer que nous sommes sur la bonne voie :
L’essai est concluant :
$ ruby tasty.rb test_rover.rb
Testing test_rover.rb
Tant qu’on y est, mieux vaut définir tout de suite la classe Tasty::Unit
,
voici à quoi devrait ressembler votre fichier tasty.rb
:
Passons maintenant au sujet principal de cette section : nous devons retrouver
le nom de la classe de test, à savoir TestRover
, depuis la classe
Tasty::Main
. Pour ce faire nous pourrions écrire un parser qui
analyserait le contenu du fichier passé en argument. Ou bien nous pouvons
compter sur les facilités d’introspection du langage Ruby. Je parie volontiers
sur cette seconde solution. Nous laisserons Ruby charger et parser le fichier de
test pour nous. Nous chargerons le fichier de la même manière qu’un autre, avec
un require
. Puis nous utilerons Object.constants
pour accéder à toutes les
constantes définies jusqu’ici (une classe est représentée par une constante) :
Si vous lancez ce programme, vous verrez une liste de toutes les constantes
définies, dont celle que nous cherchons, TestRover
:
$ ruby tasty.rb test_rover.rb
Object
Module
Class
BasicObject
...
SimpleDelegator
Tasty
Rover
TestRover # <============================
RUBYGEMS_ACTIVATION_MONITOR
Attention, il s’agit d’un tableau de symboles. Vous pouvez vous en convaincre
en changeant de méthode d’affichage. Remplacez puts
par p
:
Vous pouvez voir qu’il s’agit de symboles :
$ ruby tasty.rb test_rover.rb
[:Object, :Module, :Class, :BasicObject, :Kernel, :NilClass, :NIL, :Data,
...
:SimpleDelegator, :Tasty, :Rover, :TestRover, :RUBYGEMS_ACTIVATION_MONITOR]
Nous pouvons sélectionner uniquement les classes commençant par Test
:
Nous avons réduit le tableau aux seules classes de test. Nous en avons une seule ici, mais nous pourrions très bien en avoir plusieurs :
$ ruby tasty.rb test_rover.rb
[:TestRover]
Il y a une convention qui est à l’oeuvre : seule une classe de test peut
commencer par Test
. Ça n’est pas un bien grand sacrifice, et nous pourrions
y remédier si besoin.
Les méthodes de test
La prochaine étape consistera à récupérer les méthodes qui sont dans la classe de test, et à les lancer.
Un peu de recherche, dans une session irb et avec la documentation Ruby,
nous montrera que nous pouvons transformer un symbole en une classe, et aussi
instancier cette classe, à l’aide de Object.const_get
:
On peut donc transformer notre tableau de symboles selon cette méthode :
Retournons dans une session irb pour voir comment obtenir les méthodes d’une
classe quelconque. Définissons une classe C
avec une méthode
method_in_class_c
pour les besoins de la cause :
La méthode instance_methods
appliquée sur une classe liste les méthodes de
cette classe. Nous retrouvons notre méthode method_in_class_c
, parmi plein
d’autres :
D’où viennent ces autres méthodes ? Ce sont les méthodes héritées ou incluses. Pour restreindre les méthodes à celles définies dans la classe C, nous devons utiliser un artifice :
Nous pouvons nous servir de ce nouveau savoir pour lister les méthodes de test :
Nous l’avons trouvé :
$ ruby tasty.rb test_rover.rb
test_it_has_a_position
Il reste à lancer chaque test en se servant de la méthode send sur une
instance de la classe de test. Nous ferons cela ailleurs que dans le
constructeur de la classe Tasty::Main. Dans une méthode run
par exemple, ça
sera plus propre :
Alors, et si on lançait les tests :
$ ruby tasty.rb test_rover.rb
test_rover.rb:6:in `initialize':
wrong number of arguments (given 2, expected 0) (ArgumentError)
from test_rover.rb:6:in `new'
from test_rover.rb:6:in `test_it_has_a_position'
Déçu ? Vous ne devriez pas, ça a parfaitement fonctionné. Le programme nous dit
qu’en ligne 6 du fichier test_rover.rb
nous tentons d’initialiser un rover
avec 2 arguments alors que la méthode initialize
de rover attends 0
arguments. Voyons cette fameuse ligne 6, dans le test nous cherchons à
initialiser un rover avec des coordonnées x et y :
Et comme notre classe Rover
est déséspérement vide, il est normal que Ruby
crashe.
Passons le premier test
Dotons la méthode Rover#initialize
de deux arguments, comme attendu :
Et le programme nous emmène au prochain problème :
$ ruby tasty.rb test_rover.rb
test_rover.rb:7:in `test_it_has_a_position': undefined method `position'
for #<Rover:0x0055778cf43a90> (NoMethodError)
On en vient facilement à bout en ajoutant la méthode Rover#position
:
L’erreur suivante est beaucoup plus intéressante :
$ ruby tasty.rb test_rover.rb
test_rover.rb:7:in `test_it_has_a_position': undefined method `assert'
for #<TestRover:0x00558edbe7a828> (NoMethodError)
Nous devons coder assert
de telle manière qu’elle produise une erreur si son
argument est différent de true
. Et pour que les classes de test puissent y
accéder, nous la placerons dans Tasty::Unit
. Nous utiliserons aussi une erreur
custom, AssertionError
:
Nous y sommes presque. La méthode assert
est codée et produit l’erreur attendue :
$ ruby tasty.rb test_rover.rb
tasty.rb:8:in `assert': Tasty::AssertionError (Tasty::AssertionError)
from test_rover.rb:7:in `test_it_has_a_position'
Que se passerait-il si nous implémentions Rover
de telle manière qu’elle
passe le test ?
Et bien rien. Il ne se passe rien.
$ ruby tasty.rb test_rover.rb
$ # <---- Cruelle absence d'affichage
En l’occurence, ce rien signifie quand même que nous avons réussi cette partie ! Le test est passé ! Ajoutons un petit quelque chose pour être tenu au courant :
Et c’est la victoire :
Les autres tests
Ajoutons le second test, mais plaçons le avant le premier (!) pour observer un phénomène curieux :
Le programme reporte bien le nouveau problème qui se trouve dans la méthode
test_it_has_a_direction_by_default
mais il n’y a aucune mention de
test_it_has_a_position
qui fonctionnait pourtant bien.
$ ruby tasty.rb test_rover.rb
test_rover.rb:7:in `test_it_has_a_direction_by_default': undefined method
`direction' for #<Rover:0x0055a03b444db0 @x=3, @y=2> (NoMethodError)
Lorsqu’une erreur se produit dans Tasty::Main#run
, le programme s’arrête
purement et simplement. Ce n’est pas du tout ce que nous voulons. Nous voulons
qu’une erreur soit rapportée, et que le programme continue en traitant le test
suivant. Commençons par remanier un peu la méthode run
en la splittant en
deux parties :
Nous pouvons alors attraper les erreurs facilement dans la méthode run_test
:
Et voilà le résultat, nous affichons à la fois les tests qui passent et ceux qui échouent :
$ ruby tasty.rb test_rover.rb
not ok - test_it_has_a_direction_by_default
#<NoMethodError: undefined method `direction' for #<Rover:0x0055a7709c03c0 @x=3, @y=2>>
test_rover.rb:7:in `test_it_has_a_direction_by_default'
...
ok - test_it_has_a_position
En dotant Rover
de la méthode position
qui suit, les tests passent :
$ ruby tasty.rb test_rover.rb
ok - test_it_has_a_direction_by_default
ok - test_it_has_a_position
Faire passer le 3ème test implique seulement d’implémenter la classe Rover
de
façon correcte. Il n’y a rien à ajouter ou à modifier dans notre framework
Tasty
.
Conclusion
Nous venons d’écrire un framework de test en quelques dizaines de lignes de code grâce aux facultés d’introspection de Ruby. C’est maintenant à votre tour de jouer en l’améliorant. Voici quelques idées :
- Faire jouer les tests dans un ordre aléatoire
- Afficher une ligne de résultat final :
X tests, Y errors
- La sortie console devrait se faire en couleur, les lignes “ok” en vert, les lignes “not ok” en rouges, et le reste en normal
- Écrire
ok - it has a position
plutôt queok - test_it_has_a_position
- Faire en sorte que des classes autres que celles de test puissent commencer par
Test
. - Le must pour un compilateur, c’est d’être écrit dans son langage. Faire pareil ici : tester Tasty avec Tasty
Pour finir, voici le code complet :
Bons tests ! À plus tard.