Le développement top-down en uad
Cet article fait suite à la conversation que j’ai eue avec Patrick Fratczak sur la manière de faire du TDD.
Voici donc la méthode que j’emploi pour faire du user acceptance testing. L’exemple décrit ici utilise rails, cucumber et rspec, mais cela fonctionne également bien avant Typolight, cucumber et phpspec.
Soit, je développe un site pour un client qui vend des peintures. Une des fonctionnalité est le catalogue de peinture. Pour compliquer un peu cet exemple (et le rendre donc plus proche de la réalité), disons que ce client veut que lorsqu’on visionne la page d’une peinture, on voit le nombre d’autres visiteurs qui la visionne en même temps. Je lui dis d’abord que cette fonctionnalité n’est pas rentable, si on prend en compte son temps/coût de développement et son retour sur investissement. Mon client m’assure que c’est rentable, grâce au principe « le monde attire le monde » : si un visiteur remarque que d’autres personnes regarde la peinture en même temps que lui, il aura plus envie de l’acheter.
Dans la user story du catalogue, on donc trouve le scénario suivant :
Scénario: Voir le détail d'une peinture Lorsque je vais sur la page de la liste des peinture Et que je suis le lien de la première peinture Alors je dois voir sa photo Et je dois voir son titre Et je dois voir son auteur Et je dois voir son prix Et je dois voir le nombre de personnes qui visualisent ce produit actuellement
Une fois que le client a validé ce scénario, j’écris les steps cucumber suivantes :
Then /je dois voir sa photo/ do response.should have_selector( '#paint .picture img[src*="paint-1-full.jpg"]' ) end Then /je dois voir son titre/ do response.should have_selector( '#paint h1.title:contains("Paint 1 test")' ) end Then /je dois voir son auteur/ do response.should have_selector( '#paint .author:contains("Paint 1 author")' ) end Then /je dois voir son prix/ do response.should have_selector( '#paint .price:contains("10€")' ) end Then /je dois voir le nombre de personnes qui visualisent ce produit actuellement/ do response.should have_selector( '#paint .viewer-count:contains("5")' ) end
Cucumber, couplé à webrat, a cet avantage de permettre de faire des tests précis des vues à base de sélecteur CSS3. De nombreuses autres méthodes sont permises, et notament toute une gamme de méthodes qui executent une action tout en la testant, par exemple .click_link( link ) fait une assertion sur l’existence de ce lien, puis le suit effectivement. Pour ma part, j’aime bien être le plus précis possible dans les sélecteurs dans mes steps afin de me permettre de composer d’un trait mes vues ensuite.
Ayant ces steps, je peux maintenant écrire ma vue :
<div id="paint">
<h1 class="title">< %= @paint.title -%></h1>
<div class="picture">
<img src="<%= @paint.imageSRC -%/>" alt="Photo de < %= @paint.title -%>" />
</div><!-- .picture -->
<div class="author">
< %= @paint.author -%>
</div><!-- .author -->
<div class="price">
< %= "%.2f" % @paint.price -%>€
</div><!-- .price -->
<div class="viewer-count">
< %= @viewer_count -%>
</div><!-- .viewer-count -->
</div><!-- #paint -->Je sais maintenant que mon controller doit assigner @paint et @viewer_count. J’ai également déjà de bon indices sur ce dont je vais avoir besoin dans mes modèles et ma base de donnée. Je peux maintenant écrire les test de mon controller. Qu’est-ce que ce controller doit faire? Il doit assigner un modèle Paint et calculer le nombre de personnes qui voient actuellement cet article. Réflichissant à cette deuxième fonctionnalité, je me dis que cela doit être une méthode de classe de Visitor, qui vérifiera les journaux de visite pour une page donnée. Je n’ai pas besoin d’en savoir plus pour l’instant.
require 'spec_helper' describe PaintsController do def mock_paint(stubs={}) @mock_paint ||= mock_model(Paint, stubs) end describe "GET show" do it "assigns the requested paint as @paint and the current viewers count as @viewer_count" do Paint.stub!( :find ).with( "1" ).and_return( mock_paint( :title => 'Paint 1 test', :imageSRC => 'paint-1-full.jpg', :author => 'Paint 1 author', :price => '10.0' ) ) Visitor.stub!( :viewer_count ).with( '/paints/show/1' ).and_return( 5 ) get :show, :id => "1" assigns[ :paint ].should equal( mock_paint ) assigns[ :viewer_count ].should equal( 5 ) end end end
Je peux maintenant écrire mon controller.
class PaintsController < ApplicationController def show @paint = Paint.find( params[:id] ) @viewer_count = Visitor.viewer_count( request.request_uri ) respond_to do |format| format.html # show.html.erb format.xml { render :xml => @paint } end end end
Désormais, je sais que j’ai besoin de deux modèles : Paint et Visitor. Je sais également à quoi ils doivent répondre : :title, :imageSRC, :author et :price pour les instances de Paint, :viewer_count pour la classe Visitor. Qu’est-ce qui sera attribut et qu’est-ce qui sera méthode dans tout cela? Je réalise maintenant que tous ce à quoi doit répondre une instance de paint est un attribut simple. Il s’agira donc d’un modèle simple avec title, imageSRC, author et price en tant que champs de la table `paints`.
À ce stade, je peux utiliser le generateur rspec_model pour créer ce modèle simple et ses tests :
./script/generate rspec_model Paint title:string imageSRC:string author:string price:float
Visitor est un modèle moins simple. Que sais-je sur lui? D’abord, il doit répondre à la méthode :viewer_count. J’écris donc tout de suite ce test.
require 'spec_helper' describe Visitor do it "should give the viewer count" do Visitor.viewer_count( '/paints/show/1' ).should equal( 5 ) end end
Je m’interroge maintenant sur comment retrouver ce nombre. Les visites doivent être loggée dans une table. Un visiteur est considéré comment visitant une page s’il l’a visité dans les 5 précédentes minutes. J’identifie un visiteur par son IP. Je dois effacer les anciennes visites pour ne pas surcharger ma db. Ok, j’ai ce qu’il faut pour écrire mes specs.
D’abord, je sais que chaque visite doit être loggé. Cela passera donc par un before_filter dans application_controller.rb . Je modifie donc mon fichier de test de mon controller ainsi :
require 'spec_helper' describe PaintsController do def mock_paint(stubs={}) @mock_paint ||= mock_model(Paint, stubs) end before( :each ) do Visitor.stub!( :log ) end describe "GET show" do it "assigns the requested paint as @paint and the current viewers count as @viewer_count" do Paint.stub!( :find ).with( "1" ).and_return( mock_paint( :title => 'Paint 1 test', :imageSRC => 'paint-1-full.jpg', :author => 'Paint 1 author', :price => '10.0' ) ) Visitor.stub!( :viewer_count ).with( 'paints/show/1' ).and_return( 5 ) get :show, :id => "1" assigns[ :paint ].should equal( mock_paint ) assigns[ :viewer_count ].should equal( 5 ) end end end
Je peux maintenant écrire le before_filter :
class ApplicationController < ActionController::Base before_filter :log_visit helper :all # include all helpers, all the time protect_from_forgery # See ActionController::RequestForgeryProtection for details def log_visit Visitor.log( request.request_uri, request.ip, Time.now ) end end
Cela m’en dit plus sur mon modèle Visitor. Il possède une méthode de classe :log. Celle ci enregistrera l’ip, la page et la date. Cette méthode devra également être responsable du vidage de la table. Cela fait une requête de plus à chaque visite, mais ça maintiendra la table presque vide. Je peux donc écrire son test :
require 'spec_helper' describe Visitor do it "should give the viewer count" do Visitor.viewer_count.should equal( 5 ) end it "should log visits" do Visitor.should_receive( :delete_all ) ip, page, date = '127.0.0.1', '/show/paint/1', Time.now Visitor.should_receive( :create ).with( :page => page, :ip => ip, :date => time ) Visitor.log( page, id, date ) end end
Je sais maintenant que mon modèle ma table visitors possède les champs :page, :ip et :date. Je peux générer le modèle.
./script/generate model Visitor page:string ip:string date:datetime
Et je peux implémenter ma méthode :log
class Visitor < ActiveRecord::Base attr_accessible :page, :ip, :date def self.log( page, ip, date ) Visitor.delete_all( [ "date < ?", 5.minutes.ago.to_s( :db ) ] ) Visitor.create( :page => page, :ip => ip, :date => date ) end end
Je sais également comment retrouver le nombre de visiteurs d’une page. Je complète donc son test :
require 'spec_helper' describe Visitor do it "should give the viewer count" do page = '/paints/show/1' Visitor.should_receive( :all ).with( :conditions => [ "page = ?", page ] ).and_return( [ 1, 2, 3, 4, 5 ] ) Visitor.viewer_count( page ).should equal( 5 ) end it "should log visits" do Visitor.should_receive( :delete_all ) ip, page, date = '127.0.0.1', '/show/paint/1', Time.now Visitor.should_receive( :create ).with( :page => page, :ip => ip, :date => time ) Visitor.log( page, id, date ) end end
Et j’écris la méthode :
class Visitor < ActiveRecord::Base attr_accessible :page, :ip, :date def self.log( page, ip, date ) Visitor.delete_all( [ "date < ?", 5.minutes.ago.to_s( :db ) ] ) Visitor.create( :page => page, :ip => ip, :date => date ) end def self.viewer_count( page ) return Visitor.all( :conditions => [ "page = ?", page ] ).length end end
À ce point, j’ai terminé l’implémentation de mon scénario. Il ne me reste plus qu’à le tester. Les specs doivent déjà passer, Pour faire passer les tests de cucumber, je dois d’abord écrire ma route et inclure mes fixtures (nombreux sont ceux qui détestent les fixtures, aujourd’hui, je les trouve personnellement plus simple d’utilisation dans le cadre de cucumber).
L’immense avantage de cette méthode est de rendre tout évident. C’est un no-brainer total et il n’y a qu’à développer, au sens strict. Plutôt que de construire un outil en tentant d’imaginer ses usages, puis d’adapter les usages en fonction de l’outil créé ( schéma du type : on va des modèles aux vues ), je défini d’abord l’usage en disant ce que mes outils doivent faire, et je les construis en conséquence ( schéma du type : vues vers modèles ).
L’immense inconvénient est que les tests ne sont pas exécutables avant l’arrivée aux modèles. Il est donc important de procéder à très petite itérations. Celle que je viens de prendre pour exemple est déjà presque trop longue (en fait, il est assez probable qu’en situation réelle, j’aurai d’abord fait le :show classique, puis j’aurais rajouté le :view_count).
22 janvier, 2010 à 0:08
Miam, miam, la salade de concombre me plait ;)
Si ce n’est qu’il faut que je me documente un peu sur cucumber maintenant pour savoir où j’écris mes tests.
Enfin, merci pour cet éclairage sur les tests unitaires et le TDD, ou plutôt le UAT !
Pfiou, une fois qu’on a compris, qu’est-ce que ça va mieux :D ! Maintenant, faut pratiquer !
23 janvier, 2010 à 1:13
J’avance ;)
2 scenarios (2 failed)
10 steps (2 failed, 6 skipped, 2 passed)
Mes scénarii en cours :
Feature: User Sessions
So that i can do things
As a registered user
I want to log in and log out
Scenario: log in
Given I am a registered user
And I am on the home page
When I login
Then I should see “Vous êtes connecté(e) !”
And I should see “Se déconnecter”
Scenario: log out
Given I am logged in
And I am on the home page
When I follow “Se déconnecter”
Then I should see “Vous êtes déconnecté(e) !”
And I should see “Se connecter”
23 janvier, 2010 à 2:25
2 scenarios (2 passed)
10 steps (10 passed)
;)
Effectivement, le développement piloté par les tests et ici même plus, par les scénarii, du moment où on a compris comment les écrire, fait gagner du temps sur le développement, temps que l’on peut consacrer au design de l’application, à son ergonomie et à faire marcher son imagination pour apporter des améliorations en continu !
Si j’avais su, je m’y serai mis plus tôt.
Maintenant, je crois que je ne m’en passerai plus …
Merci pour le partage de tes connaissances !
25 janvier, 2010 à 23:33
Salut,
Je reviens pour d’autres questions.
Alors, maintenant que j’ai mon scénario User Sessions qui passe (avec Authlogic d’ailleurs), il faut que j’avance dans mon projet de site web en mettant en place des rubriques genre Blog/Actu, A propos, … mais qu’en est-il de la page d’accueil ou plus généralement de la structure du site en lui-même ?
Est-ce que tu crée également des scénarios du style :
So that i am a visitor
And not a registered user
Then I should see the links home,blog,about,contact
So that i am a visitor
And a registered user
And an admin user
Then I should see the links home,blog,about,contact,administration
???
27 janvier, 2010 à 18:24
Hello,
Effectivement, je ne m’en passe plus non plus, au point de m’en servir même pour mes projets PHP :)
Pour ce qui est des différents rôle, je fais généralement un .feature différent pour chaque rôle. En général, les liens qu’un admin voit en plus se rattache à une autre feature. Dans ton cas, j’imagine que c’est :
Feature : Manage articles
In order to keep the website up to date
As an admin
I want to change content
Background: I am logged in as admin
Scenario: Access backend
Given I am on the homepage
When I follow “administration”
I should see “Admin Section”
Scenario: Change a page content
[etc]
À la rigueur, dans un .feature visiteur, tu peux mettre quelque part un :
I should not see “administration”
Mais comme je le disais, je teste rarement ce qui ne doit pas être, sauf si un client appuie sur le fait que quelque chose ne doit pas être là (ça devient un étant, en quelque sorte, puisque c’est clairement stipulé comme spec).
Tiens au fait, je ne l’ai pas précisé, mais cucumber parle français :) Si tu veux écrire un scénario pour un client français, il vaut mieux éviter de lui demander de signer des scénarii anglais.
Pour qu’un user story soit reconnu comme étant en français, il suffit de placer, en première ligne du .feature :
# language: fr
Par contre, tu devras réécrire les webrat_steps en fr. J’utilise celle-là, si ca t’intéresse :
http://www.olivier-elmekki.com/tl_files/webrat_steps_fr.txt
Il manque certains steps qui ont été rajoutées récemment, mais ça suffit comme base.