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 :

# fichier test_rover.rb final.

require 'rover'

class TestRover < Tasty::Unit

  def test_it_has_a_position
    rover = Rover.new(3, 2)
    assert(rover.position == [3, 2])
  end

  def test_it_has_a_direction_by_default
    rover = Rover.new(3, 2)
    assert(rover.direction == 'north')
  end

  def test_it_has_a_given_direction
    rover = Rover.new(3, 2, 'west')
    assert(rover.direction == 'west')
  end

end

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 :

# fichier rover.rb

class Rover
end

Puis définissons notre premier test, dans un fichier test_rover.rb. Ce premier test va nous guider pendant un bout de temps :

# fichier test_rover.rb

require_relative 'rover'

class TestRover < Tasty::Unit

  def test_it_has_a_position
    rover = Rover.new(3, 2)
    assert(rover.position == [3, 2])
  end

end

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 :

# fichier tasty.rb

module Tasty

  class Main
    def initialize(filename)
      puts "Testing #{filename}"
    end
  end

end

main = Tasty::Main.new(ARGV[0])

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 :

# fichier tasty.rb

module Tasty

  class Unit
  end

  class Main
    def initialize(filename)
      puts "Testing #{filename}"
    end
  end

end

main = Tasty::Main.new(ARGV[0])

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) :

module Tasty

  class Main
    def initialize(filename)
      require File.join(Dir.pwd, filename)
      puts Object.constants
    end
  end

end

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 :

def initialize(filename)
  require File.join(Dir.pwd, filename)
  p Object.constants
end

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 :

# fichier tasty.rb

module Tasty

  class Unit
  end

  class Main
    def initialize(filename)
      require File.join(Dir.pwd, filename)
      p Object.constants.select { |name| name.to_s.start_with?('Test') }
    end
  end

end

main = Tasty::Main.new(ARGV[0])

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 :

$ irb
>> :Module
:Module
>> Object.const_get(:Module)
Module < Object
>> Object.const_get(:Module).new
#<Module:0x0055e0036e5580>

On peut donc transformer notre tableau de symboles selon cette méthode :

class Main
  def initialize(filename)
    require File.join(Dir.pwd, filename)
    classes = Object.constants.select { |name| name.to_s.start_with?('Test') }
    classes.map! { |name| Object.const_get(name) }
  end
end

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 :

$ irb
>> class C
>>   def method_in_class_c; end
>> end

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 :

>> puts C.instance_methods
method_in_class_c # <--------------------
methods
singleton_methods
protected_methods
private_methods
public_methods
instance_of?
...

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 :

>> puts C.instance_methods(false)
method_in_class_c

Nous pouvons nous servir de ce nouveau savoir pour lister les méthodes de test :

# fichier tasty.rb

module Tasty

  class Unit
  end

  class Main
    def initialize(filename)
      require File.join(Dir.pwd, filename)
      classes = Object.constants.select { |name| name.to_s.start_with?('Test') }
      classes.map! { |name| Object.const_get(name) }
      
      classes.each do |c|
        c.instance_methods(false).each do |m|
          puts m
        end
      end
    end
  end

end

main = Tasty::Main.new(ARGV[0])

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 :

# fichier tasty.rb

module Tasty

  class Unit
  end

  class Main
    def initialize(filename)
      require File.join(Dir.pwd, filename)
      @classes = Object.constants.select { |name| name.to_s.start_with?('Test') }
      @classes.map! { |name| Object.const_get(name) }
    end

    def run
      @classes.each do |class_under_test|
        instance = class_under_test.new
        class_under_test.instance_methods(false).each do |m|
          instance.send(m)
        end
      end
    end
  end

end

main = Tasty::Main.new(ARGV[0])
main.run

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 :

rover = Rover.new(3, 2)

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 :

# fichier rover.rb
class Rover
  def initialize(x, y)
  end
end

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 :

# fichier rover.rb
class Rover
  def initialize(x, y)
  end

  def position
  end
end

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 :

module Tasty

  class AssertionError < StandardError
  end

  class Unit
    def assert(boolean)
      raise AssertionError unless boolean
    end
  end

end

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 ?

# fichier rover.rb
class Rover
  def initialize(x, y)
    @x = x
    @y = y
  end

  def position
    [@x, @y]
  end
end

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 :

module Tasty
  class Main
    def run
      @classes.each do |class_under_test|
        instance = class_under_test.new
        class_under_test.instance_methods(false).each do |m|
          instance.send(m)
          puts "ok - #{m}" # <---------------
        end
      end
    end
  end
end

Et c’est la victoire :

$ ruby tasty.rb test_rover.rb 
ok - test_it_has_a_position

Les autres tests

Ajoutons le second test, mais plaçons le avant le premier (!) pour observer un phénomène curieux :

# fichier test_rover.rb
require_relative 'rover'

class TestRover < Tasty::Unit
  
  def test_it_has_a_direction_by_default
    rover = Rover.new(3, 2)
    assert(rover.direction == 'north')
  end

  def test_it_has_a_position
    rover = Rover.new(3, 2)
    assert(rover.position == [3, 2])
  end

end

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 :

class Main
  def run
    @classes.each do |under_test|
      instance = under_test.new
      under_test.instance_methods(false).each { |m| run_test(instance, m) }
    end
  end

  def run_test(instance, method)
    instance.send(method)
    puts "ok - #{m}"
  end
end

Nous pouvons alors attraper les erreurs facilement dans la méthode run_test :

  def run_test(instance, method)
    instance.send(method)
    puts "ok - #{method}"
  rescue => ex
    puts "not ok - #{method}"
    puts ex.inspect
    puts ex.backtrace
  end

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 :

def position
  'north'
end
$ 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 que ok - 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 :

# fichier rover.rb
class Rover
  def initialize(x, y, direction='north')
    @x = x
    @y = y
    @direction = direction
  end

  def position
    [@x, @y]
  end

  attr_reader :direction
end
# fichier test_rover.rb
require_relative 'rover'

class TestRover < Tasty::Unit

  def test_it_has_a_direction_by_default
    rover = Rover.new(3, 2)
    assert(rover.direction == 'north')
  end

  def test_it_has_a_position
    rover = Rover.new(3, 2)
    assert(rover.position == [3, 2])
  end

  def test_it_has_a_given_direction
    rover = Rover.new(3, 2, 'west')
    assert(rover.direction == 'west')
  end

end
# fichier tasty.rb
module Tasty

  class AssertionError < StandardError
  end

  class Unit
    def assert(boolean)
      raise AssertionError unless boolean
    end
  end

  class Main
    def initialize(filename)
      require File.join(Dir.pwd, filename)
      @classes = Object.constants.select { |name| name.to_s.start_with?('Test') }
      @classes.map! { |name| Object.const_get(name) }
    end

    def run
      @classes.each do |under_test|
        instance = under_test.new
        under_test.instance_methods(false).each { |m| run_test(instance, m) }
      end
    end

    def run_test(instance, method)
      instance.send(method)
      puts "ok - #{method}"
    rescue => ex
      puts "not ok - #{method}"
      puts ex.message
      puts ex.backtrace
    end
  end

end

main = Tasty::Main.new(ARGV[0])
main.run

Bons tests ! À plus tard.