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