Olivier El Mekki

Maîtriser les tests unitaires dans Ruby on Rails

Maintenant que vous maîtrisez les fixtures (et que vous les avez écrit et testés), vous savez que votre réseau de relations entre les modèles ne posera pas de problèmes.

La tâche va consister dès lors à écrire plus précisément vos modèles, et en particulier vos validations. Bien entendu, nous allons commencer par écrire les tests :]

Introduction à Test:Unit

Test::Unit est en fait un framework de test intégré à la library standard de ruby. Pour mieux le comprendre, nous allons laisser Rails de côté quelques instants.

Test::Unit mêle quatre notions : les assertions, les testcases, les testsuites et les fixtures.

Une assertion est l’unité la plus élémentaire. Il s’agit d’une expression effectuant un test. Si le test est passé, rien de particulier ne se passe ; si le test échoue, une erreur sera affichée. La méthode de base est assert. Elle évalue si l’expression passée est vraie ou fausse. Vous pouvez spécifier un message personnalisé au cas ou le test échoue.

assert true
assert 1 < 2
assert false, "évidemment que non"

Les deux premières assertions passeront, la dernière échouera. Il existe tout un jeu de méthodes d’assertions intéressantes, comme assert_equal, assert_match, assert_not_same, assert_not_nil, assert_nothing_raised, etc. Je vous laisse regarder la doc officielle pour en faire le tour.

Un testcase est une classe dérivée de Test::Unit::TestCase qui regroupera les tests propres à une classe (ou pas : vous êtes libre, dans Test::Unit, d’en faire ce que vous voulez, mais il est conseillé de faire ainsi, et Rails en fait un par modèle).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
require 'test/unit'
require 'Dog'
require 'Cat'
require 'Person'
 
class DogTest < Test::Unit::TestCase
  def test_should_create_tame_dog_and_feed_him
    doggy    = Dog.new
    grandma  = Person.new "rotten meat"
    assert grandma.dog = doggy, "grandma doesn't want a dog"
    assert_kind_of Animal, doggy, "that dog is weird"
    assert doggy.feed
  end
 
  def test_should_know_what_is_good_for_him
    doggy    = Dog.new
    kitty    = Cat.new
    grandma  = Person.new "rotten meat"
    assert doggy.feed( kitty )
    assert_raise { doggy.feed grandma }
  end
end

Il s’agit, concrètement, d’un ensemble de méthodes qu’on définira et qu’on nommera chacune test_quelquechose. Le préfixe test_ est obligatoire. On donne généralement un nom représentatif à ce test, étant donné que ce nom apparaîtra si le test échoue. On peut écrire des méthodes sans le préfixe test_ mais elles ne seront donc pas considéré comme des tests et ne seront pas exécutées automatiquement.

Car c’est l’intérêt de cette classe : lorsqu’on exécute un fichier qui require ‘test/unit’, il lance automatiquement les tests les uns après les autres et affiche un rapport à la fin.

Isoler ainsi les assertions dans des testcases a pour intérêt évident une plus grande sémantique et une meilleure organisation des tests, mais cela permet aussi de partager des fixtures. Contrairement à ce que nous avons vu dans l’article précédent, les fixtures de Test::Unit standard ne sont pas des fichiers YAML, c’est un ajout de Rails. Dans les tests standards, on gère les fixtures grâce à deux méthodes rajoutées dans le testcase : setup et teardown.

setup sera appelé avant l’exécution de chaque méthode du testcase. Cela signifie que les fixtures qui sont placés dedans sont réinitialisés entre deux tests. Si vous utilisez des destructeurs, teardown sera exécuté après chaque test (cela peut être intéressant dans une perspective de logging aussi). L’exemple précédent peut donc être simplifié ainsi :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
require 'test/unit'
require 'Dog'
require 'Cat'
require 'Person'
 
class DogTest < Test::Unit::TestCase
  def setup
    @doggy    = Dog.new
    @kitty    = Cat.new
    @grandma  = Person.new "rotten meat"
  end
 
  def test_should_create_tame_dog_and_feed_him
    assert @grandma.dog = @doggy, "grandma doesn't want a dog"
    assert_kind_of Animal, @doggy, "that dog is weird"
    assert @doggy.feed
  end
 
  def test_should_know_what_is_good_for_him
    assert @doggy.feed( @kitty )
    assert_raise { @doggy.feed @grandma }
  end
end

Bien qu’utilisant son propre système de fixture, Rails sait aussi se servir de ce système-là, ce qui vous permet d’exécuter du code arbitraire avant et après chaque méthode de test.

Enfin, une testsuite regroupe plusieurs testcases. C’est principalement un outil de convenance pour vous empêcher de devoir exécuter tous vos tests manuellement les uns après les autres, ou de devoir les mettre dans le même fichier. Ainsi, il vous suffit de faire un fichier contenant :

require 'test/unit'
require 'my_tests/DogTest'
require 'my_tests/CatTest'
require 'my_tests/PersonTest'
# etc

Et tous les tests seront lancés à l’exécution de ce fichier.

Voilà, vous avez maintenant une connaissance solide des bases de Test::Unit, nous allons pouvoir revenir à Rails.

Test::Unit dans Rails

Peut-être faut-il lever tout de suite un quipropos. Dans le répertoire test/ d’une application Rails, vous pourrez voir unit/, functional/ et integration/. Le fait qu’un répertoire se nomme unit ne doit pas vous induire en erreur : tous trois utilisent Test::Unit (avec des ajouts propres à Rails), et plus particulièrement, Test::Unit::TestCase, ou dérivé.

On peut noter un changement de focalisation entre les trois, allant de unit/, très précis et particulier, à integration, très abstrait et dont les tests simulent des cas concrets de parcours dans l’application par un utilisateur.

Pour lancer les tests, on peut utiliser une de ces formes de commande :

ruby test/unit/user_test.rb
rake test:units
rake test:functionals
rake test:integration
rake test

La première lance un unique testcase, ici celui du modèle User. Les trois suivants lance l’ensemble des testcases d’une des trois famille de tests. Le dernier lance tous les tests.

Comme je l’ai dit plus haut, Rails permet un gestion des fixtures plus sophistiquée que Test::Unit. Vous avez déjà écrit vos fixtures. Pour les charger dans vos tests, il suffit d’indiquer le nom du fixture que vous voulez charger dans la classe de votre testcase, par exemple :

class AccountTest < ActiveSupport::TestCase
  fixtures :accounts, :users
  # ...
end

Si vous désirez charger tous les fixtures, vous pouvez spécifier :

fixtures :all

Par Ailleurs, vous pouvez utiliser des helpers dans vos tests, que vous placerez dans test/test_helper.rb.

Enfin, Rails rajoute deux méthodes très utiles à Test::Unit::Assertions : assert_difference et assert_no_difference.

Voilà, le plus lourd est passé. Vous avez les bases pour vous servir de l’ensemble des tests de rails. Oui, c’est la petite surprise de la notion de test unitaire dans Rails : elle crée la confusion avec Test::Unit qui est en fait la base de tout.

Étant donné que le terme Test::Unit est peu mentionné dans Rails (il y a même une classe ActiveSupport::Testcase qui ne fait qu’hériter de Test::Unit::TestCase), je parlerai des tests relatifs à test/unit/ lorsque je parlerai de tests unitaires.

Au volant!

Voyons maintenant comment se servir des tests unitaires dans le contexte du test driven development.

L’implémentation MVC de Rails est fortement portée sur les modèles. Cela veut dire que vous devez avoir des modèles très chargés et des contrôleurs très maigres (je ne parviens pas à retrouver la citation exacte, désolé).

Pourtant l’écriture des tests des modèles, préalable à l’écriture des modèles même, n’est pas si conséquente que cela. La raison en est surtout que vous saurez de quoi à besoin votre modèle lorsque vous penserez vos contrôleurs.

Une première écriture de test de modèle se concentre donc généralement sur le test du CRUD et des validations. Un exemple typique sera donc :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
require 'test_helper'
 
class AccountTest < ActiveSupport::TestCase
  fixtures :accounts, :users
  # create
  def test_should_create
    assert_difference 'Account.count' do
      Account.create :user => users( :quentin), :name => 'test_should_create_account_name'
    end
  end
 
  def test_validations
    assert_no_difference 'Account.count' do
      account = Account.create
      assert_not_nil account.errors.on( :user_id ), "Un compte appartient à un user"
      assert_not_nil account.errors.on( :name ), "Les comptes sont distingués par leur nom par les visiteurs"
    end
  end
 
  def test_name_should_be_unique
    assert_no_difference 'Account.count', "Les comptes sont distingués par leur nom par les visiteurs" do
      account = Account.create :user => users( :quentin), :name => accounts( :account1 ).name
      assert_not_nil account.errors.on( :name )
    end
  end
 
  # read
  def test_should_read
    account = Account.create :user => users( :quentin ), :name => "test_read_account_name"
    assert_nothing_raised do
      second = Account.find_by_name 'test_read_account_name'
      assert_equal second.id, account.id
    end
  end
 
  # update
  def test_should_update
   assert (accounts( :account1 ).update_attributes :name => "test_update_account_name")
  end
 
  # delete
  def test_should_delete
    assert_difference 'Account.count', -1 do
      users( :quentin ).destroy
    end
  end
 
  def test_all_clear
    flunk
  end
end

On test donc principalement ici les tâches classique du modèle et les validations. Ce n’est pas insignifiant. Lorsque vous vous enfoncerez dans le développement de votre application et que vous écrirez d’autres modèles, il y a de fortes chances pour que vous n’ayez plus bien en tête ce qui se passe dans ce modèle-ci. Lancer régulièrement les tests vous assurera de la solidité de votre modèle.

De plus, vous vous êtes sûrement déjà retrouvé dans le cas où des bugs sont apparus parce que vous avez modifié du code dont vous ne vous souveniez plus du sens. Pour éviter ça, je vous conseille d’abuser des messages personnalisés des assertions. Plutôt que de simplement répéter la raison pour laquelle le test échoue, et qui est déjà comprise dans le nom de la méthode de test, votre message personnalisé peut expliquer pour quelles raisons contextuelles le test doit passer. C’est le meilleur moyen pour vous rappeler ce que vous avez fait sur les fichiers précédents sans devoir aller les rouvrir, chercher d’où provient l’erreur et lire les éventuels commentaires que vous y avez laissé.

Revenons à la décortication de l’exemple.

Vous avez peut-être remarqué que l’expression passée à assert_difference ou assert_no_difference est un string. C’est indispensable. C’est fonction évalue bien une différence numérique, mais elle doivent avoir en paramètre un expression dans un string qui sera passé à eval. La raison de cela est que cette expression est évaluée deux fois : une fois au début, une fois à la fin. La soustraction est faite ensuite entre le résultat final et le résultat originel. assert_difference prend en second argument la valeur que doit avoir cette soustraction. Cela implique qu’on doit la spécifier si on veut ajouter un message personnel (troisième argument). La valeur par défaut est 1, ce qui veut dire que assert_difference test par défaut s’il y a eu un ajout sur l’expression, typiquement : le nouvel objet a-t’il été enregistré?

users( :quentin ) et accounts( :account1 ) permettent de retrouver des fixtures selon leur nom.

Un dernier détail à propos de ce code, la dernière méthode. flunk est une fonction permettant de faire échouer systématiquement le test. C’est en fait un raccourci pour assert false. Lors de la première écriture d’un test et de son modèle (ou autre), une dernière méthode avec un flunk permet de signifier qu’il y a encore des choses à écrire. Votre fichier de test, outre le fait d’être un observer et un reminder, est aussi une todo list. Lorsque vous aurez fini la première écriture de votre modèle et que son test passera sans problème, vous pourrez supprimer la méthode de flunk.

Conclusion

Vous venez d’avaler la partie la plus complexe pour qui découvre les outils de tests de Rails. Les tests d’intégration et fonctionnels seront plus complexes, mais il réutiliseront les même outils et structures, avec quelques méthodes ajoutées par Rails pour vous simplifier la vie.

Dans le cadre du Test Driven Development, vous aviez d’abord écrit les relations de vos modèles et vos migrations grâce aux fixtures, vous écrivez maintenant les validations et peut-être quelques méthodes de vos modèles grâce aux tests unitaires.

Vous reviendrez sur ces tests, lorsque vous vous attaquerez à vos contrôleurs et que vous voudrez ajouter de nouvelles méthodes à vos modèles. Vous créerez probablement d’autres modèles, également, en fonction des précisions que votre client vous fournira, des nouvelles requêtes et de tous les imprévus qui pimentent la vie d’un développeur. Notez d’ailleurs que dans le cadre d’un développement agile et des fameux phantasmes de “je suis en face du client, il me demande une modif, je change deux lignes, je lui montre, il n’en revient pas”, les tests vous seront d’un grand secour, afin que ça ne devienne pas “je suis en face du client, il me demande une modif, je change deux lignes, ça casse tout, je cherche le bug, il s’impatiente, je lui dis que ça prendra deux minutes, je comble le vide en baratinant du discours technique, je trouve pas, ça m’enerve”.

Ce qui compte, c’est que lorsque vous en aurez fini avec l’écriture de ces tests et des modèles qu’ils couvrent, vos modèles seront pleinement opérationnels et solides. Il est alors temps de s’attaquer aux contrôleurs (oui, vous avez fait tout ça en une journée, hormis la map initiale).

13 Réponses à “Maîtriser les tests unitaires dans Ruby on Rails”

  1. Patfrat dit:

    Bonsoir,
    Ha zut, je viens juste de finir de lire ces articles :
    * Maîtriser les fixtures
    * Maîtriser les tests unitaires

    Et je voulais m’attaquer à :
    * Maîtriser les tests fonctionnels
    * Maîtriser les tests d’intégration

    J’ai suivi le fil des deux premiers articles avec grand intérêt car étant développeur RoR, il fallait bien que je me mette aux tests unitaires. Et pour comprendre par quel bout attaquer ce sujet, je suis arrivé là et j’ai commencé à comprendre … mais pas fini :D

    A quand la suite ?
    Merci déjà pour cet éclairage qui me fera coucher moins bête ce soir et levé plus efficace demain pour le boulot ;)

  2. kik dit:

    Bonjour Patrick,

    Oui effectivement, je ne les ai pas terminé, désolé :)

    En fait, il y a une bonne raison à ça : alors que je me commençais à me sentir plus qu’à l’aise avec Test::Unit, j’ai découvert RSpec, puis Cucumber et je n’ai pu m’empêcher de sauter dessus (ce que je ne regrette pas, aujourd’hui). Le monde de Rails, tu sais comme ça change vite :)

    Test::Unit est toujours le framework de test par défaut dans rails, aussi ces informations sont toujours valables (bien qu’il y ait sûrement eu des améliorations depuis).

    Cela dit, si tu as compris le fonctionnement des test unitaires, les tests fonctionnels ne te seront pas difficiles à appréhender. Il faut juste comprendre que tu ne testes pas vraiment des requêtes mais des actions des controllers.

    Ainsi, dans un test tu exécutes une action crud en donnant le nom de l’action et éventuellement ses paramètres sous forme de symbole. Ex:
    get :show

    ou

    post :create, :name => ‘foo’, :count => 5

    Tu peux ensuite tester le resultat avec @response (contient la réponse) et assigns. Ce dernier test si une @variable a bien été assigné par le controller pour les vues :
    assert assigns(:user )

    Voilà, j’espère que cela t’aidera, mais je te conseille vraiment d’essayer rspec :)

  3. Patfrat dit:

    En fait, j’ai connaissance aussi de RSpec et Cucumber mais je voulais déjà commencer à la base, avec Test::Unit, en pensant par la suite me tourner vers Cucumber … mais grâce à tes deux articles, j’ai bien compris l’intérêt du développement piloté par les tests et le principe des tests unitaires.
    Maintenant, peut-être vais-je directement me plonger dans cucumber ?
    Merci !

  4. kik dit:

    De rien, merci à toi pour ton retour :)

    Cucumber est le story runner de RSpec (bien qu’il puisse être utilisé sans). En gros, dans le tercet unit/functional/integration, il s’occupe des test d’intégration. Il risque donc de ne pas te suffire :)

    Il a cependant un avantage énorme sur les autres : il peut être utilisé hors-ruby. Je m’en sers personnellement, couplé avec phpspec, pour tester mes sites php.

    Enfin, cucumber va un peu plus loin que du TDD : son auteur le qualifie d’outil de “user acceptance driven development”. Je test ça en conditions réelles dans mes contrats, ca marche pas mal. Tu écris des user stories, tu les fais valider par tes clients et ça devient des tests. Ça change radicalement la façon de développer, même quand on s’est fait au TDD :)

  5. Patfrat dit:

    Ok pour RSpec et Cucumber, j’ai deux sites à faire en RoR et je vais essayer des les faire avec les bonnes pratiques TTD avant de m’attaquer à l’UADD :D
    Allons-y par étape. C’est une gymnastique intellectuelle intéressante mais il faut bien se l’approprier.
    Par contre, petite question, j’ai l’impression que c’est un peu comme une équation de maths à resoudre par une astuce … comment savoir quels tests faire ? Par quoi commencer finalement ? Quels tests sont judicieux et quels tests sont inutiles … là, je me perds un peu.

  6. kik dit:

    Oui, c’est surement ce qu’il y a de plus dur à se représenter :) En fait, il y a plusieurs écoles sur ce point, je ne répond que pour moi.

    Déjà, ce qui met tout le monde d’accord est ce qu’il ne faut *pas* tester : il ne faut pas tester du code qui n’est pas écrit par toi. Donc, on ne test pas qu’un controller appelle la bonne méthode, on ne test pas qu’une association fonctionne bien, etc. C’est aux tests du core de rails de s’occuper de ça.

    Ensuite, il devient plus clair de savoir quoi tester à partir du moment où tu écris tes tests avant d’écrire ton code. Décrit simplement dans ton test ce que tu veux que ton code fasse. Si par exemple tu as une méthode de modèle appelée fullname qui doit rassembler les entrées firstname et lastname en ajoutant un préfix de titre au début qui sera passé en argument (ex: “Monseigneur Jean Dupont”, désolé pour l’exemple :P ), tu écriras un test avant d’écrire cette méthode qui dira que ton modèle, lorsqu’on appelle cette méthode, doit recevoir l’argument “Monseigneur”, doit appeler “firstname” et “lastname” et doit répondre “Monseigneur Jean Dupont”.

    Les partisans du full test coverage te diront aussi que tu dois écrire un test qui vérifie qu’il n’est pas possible d’appeler la méthode sans arguments, qu’une erreur doit être générée si l’instance du modèle n’a pas d’attribut firstname défini, etc.

    Pour ma part, je trouve plus naturel de développer “à l’envers”. Plutôt que de penser le schémas de ma base de donnée en essayant de me représenter tout ce dont j’ai besoin, puis faire des tests de modèles en poussant ma réflexion pour deviner quelle méthodes il doivent avoir, et donc quels tests faire, je procède ainsi :

    1°) je fais valider mes users stories d’une fonctionnalité. Chaque scénario test une action précise.

    2°) je prend le scénario d’une action, et j’écris la vue qui y correspond. Le scénario cucumber étant son test, je sais précisément ce que doit être ma vue.

    3°) Ayant écrit la vue, je sais quels variables doivent y être assignées. Je peux donc écrire mon test de controller pour cette action et je test que tout est assigné comme il le faut.

    4°) Sachant précisément ce qui doit être assigné, je peux écrire l’action de mon controller. Comme dans le cas de la vue, le simple fait de l’écrire me fait savoir ce dont on besoin mes modèles.

    5°) Je peux donc maintenant écrire les tests de mes modèles. Je sais de quel modèle j’ai besoin, quelles méthodes ils doivent avoir avec quels paramètres et quelle valeur de retour.

    6°) j’écris ces modèles. Je sais désormais quel schémas de db je dois avoir à ce moment.

    7°) je recommence à 2°) jusqu’à ce que j’ai épuisé mes scénarii et que tous les tests passent. Puis j’écris l’user story d’une autre fonctionnalité.

    Cette méthodologie me permet de savoir précisément quoi tester. Maintenant, comme je te disais, elle est très personnelle :)

    Si je devais me risquer à formuler une règle universelle sur quoi tester, je dirais que tu dois tester le comportement de tes classes, en écrivant ce que tu te représente au moment où tu la conçois. Ma classe doit avoir telle méthode qui retourne ceci en faisant cela. Test que ceci est retourné, et que cela a été appelé.

  7. Patfrat dit:

    Intéressante notre discussion, et je la continue ici pour ceux qui tomberont sur tes articles … ça peu aider.

    Pour continuer donc :

    Les Users Stories, les scenarii en fait qui composent ton application, t’aident à savoir de quelles méthodes (actions) et de quelles variables tu vas avoir besoin dans tes classes…

    Mais est-ce que cela peut également t’aider à savoir de quelles classes tu vas avoir besoin finalement ?
    Je suppose que oui !

  8. kik dit:

    Oui, effectivement :) Peut-être qu’un exemple serait plus simple. Je m’apprêtais à l’écrire ici, mais je vais plutôt en faire un post, à cause de la longueur et du formatage.

    Je te post ça dès que j’ai fini.

  9. Patfrat dit:

    Super, merci !

  10. Patfrat dit:

    Ha ben, en attendant avec impatience ton article, je suis en train de suivre celui-ci : http://asciicasts.com/episodes/155-beginning-with-cucumber

    Je commence à entrevoir la puissance du concombre ;)

  11. kik dit:

    hop, c’est fait : http://blog.olivier-elmekki.com/2010/01/21/le-developpement-top-down-en-uad/

  12. kik dit:

    “Je commence à entrevoir la puissance du concombre ;)

    Oh que oui, on en deviendrait végétarien :]

  13. Patfrat dit:

    Oui, d’ailleurs, je mange là : http://blog.olivier-elmekki.com/2010/01/21/le-developpement-top-down-en-uad/
    Je mange …
    Merci ! Je te ferai des commentaires une fois que j’aurai fini la salade ;)

Laisser un commentaire