Olivier El Mekki

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

5 Réponses à “Le développement top-down en uad”

  1. Patfrat dit:

    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 !

  2. Patrick Fratczak dit:

    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”

  3. Patrick Fratczak dit:

    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 !

  4. Patrick Fratczak dit:

    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

    ???

  5. kik dit:

    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.

Laisser un commentaire