#275 How I Test
- Download:
- source codeProject Files in Zip (92.2 KB)
- mp4Full Size H.264 Video (26.4 MB)
- m4vSmaller H.264 Video (16.1 MB)
- webmFull Size VP8 Video (18.1 MB)
- ogvFull Size Theora Video (37.8 MB)
À partir de cet épisode, nous verrons plus souvent le sujet des tests. Cette fois-ci, nous allons montrer comment nous aurions écrit les tests pour l'épisode précédent [regarder, lire], plus spécifiquement, le lien “forgotten password” (mot de passe oublié) que nous avons ajouté au formulaire de connexion.
Lorsque nous avons commencé le dernier épisode, nous avions une application avec un formulaire de connexion. L'application avait un système d'authentification assez simple et un formulaire de connexion mais pas de checkbox “remember me” (se souvenir de moi) ou de lien “forgotten password” (mot de passe oublié). Nous les avons donc ajoutés. Nous allons de nouveau ajouter le lien mais cette fois, nous allons le faire grâce au Test Driven Development (TDD, Développement Dirigé par les Tests).
La dernière fois, nous avons testé l'application dans le navigateur. Cette fois, nous garderons le navigateur fermé et écrirons du code pour tester la fonctionnalité. Nous l'ouvrirons seulement lorsque nous aurons besoin de nous concentrer sur l'expérience utilisateur.
Pour nous aider à écrire nos tests, nous devons ajouter quelques gems relatives aux tests dans le Gemfile
de notre application. Nous utilisons Rails 3.1 mais tout ce que nous allons faire fonctionnera sous Rails 3.0.
source 'http://rubygems.org' gem 'rails', '3.1.0.rc4' gem 'sqlite3' # Asset template engines gem 'sass-rails', "~> 3.1.0.rc" gem 'coffee-script' gem 'uglifier' gem 'jquery-rails' gem "rspec-rails", :group => [:test, :development] group :test do gem "factory_girl_rails" gem "capybara" gem "guard-rspec" end
Nous utilisons RSpec mais n'importe quel framework de test ferait l'affaire. Notez que, contrairement aux autres gems de test, RSpec est également dans le groupe development
de façon à ce que ses tâches Rake fonctionnent correctement. Nous avons aussi choisi d'utiliser Factory Girl plutôt que les fixtures, Capybara pour simuler les interactions du navigateur et Guard pour lancer les tests automatiquement. Chacune de ces gems a été vue dans un épisode précédent : Factory Girl était abordée dans l'épisode 158 [regarder, lire], Capybara dans l'épisode 257 [regarder, lire] et Guard dans l'épisode 264 [regarder, lire].
Nous pouvons installer les gems grâce à la commande bundle
. Une fois cela fait, nous allons configurer RSpec en lançant
$ rails g rspec:install
Nous allons créer quelques dossiers dans celui nommé /rspec
: un dossier support
pour les fichiers de support ainsi qu'un dossier models
et un dossier routing
qui sont requis par Guard.
$ mkdir spec/support spec/models spec/routing
Il est également temps de lancer l'initialiseur de Guard.
$ guard init rspec
Puisque nous développons sous OS X, nous devons installer la gem rb-fsevent
de façon à ce que Guard puisse détecter les changements de fichiers. Une fois cela fait, nous allons lancer Guard dans un nouvel onglet de Terminal et le garder actif en arrière-plan.
$ guard Please install growl gem for Mac OS X notification support and add it to your Gemfile Guard is now watching at '/Users/eifion/auth' Guard::RSpec is running, with RSpec 2! Running all specs No examples found.
Lorsque nous avons lancé le générateur RSpec, il a créé un fichier /spec/spec_helper.rb
. Nous devons activer Capybara dans ce fichier. Pour ce faire, nous allons ajouter une ligne require 'capybara/spec'
.
# This file is copied to spec/ when you run 'rails generate rspec:install' ENV["RAILS_ENV"] ||= 'test' require File.expand_path("../../config/environment", __FILE__) require 'rspec/rails' require 'capybara/rspec' # rest of file...
Nous allons également suivre le conseil donné dans les commentaires du fichier et retirer la ligne contenant le chemin vers les fixtures puisque nous n'allons pas les utiliser.
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures config.fixture_path = "#{::Rails.root}/spec/fixtures"
Notre premier test
Nous sommes maintenant parés pour commencer nos tests et nous allons démarrer avec un test d'intégration que nous allons appeler password_reset
.
$ rails g integration_test password_reset
Le générateur RSpec va prendre la main et créer ce que l'on appelle une request spec (spécification de requête). Le code par défaut dans la spec ressemble à ceci :
require 'spec_helper' describe "PasswordResets" do describe "GET /password_resets" do it "works! (now write some real specs)" do # Run the generator again with the --webrat flag if you want to use webrat methods/matchers get password_resets_path response.status.should be(200) end end end
Nous allons retirer la spec par défaut et la remplacer par la nôtre et tester qu'un e-mail est envoyé à un utilisateur lorsqu'il demande une réinitialisation de son mot de passe. Nous aurons besoin d'un enregistrement User
avec lequel travailler. Nous pourrions passer par la page d'inscription et enregistrer notre utilisateur mais il est préférable de se concentrer uniquement sur ce que nous voulons tester. Nous allons donc utiliser une factory (fabrique). Avant de commencer nos tests, nous allons créer, dans le fichier factories.rb
du dossier /spec
, une factory pour le modèle User
. Ce nom et cet emplacement signifient que toutes les factories que nous allons définir dedans seront automatiquement chargées par Factory Girl.
Factory.define :user do |f| f.sequence(:email) { |n| "foo#{n}@example.com" } f.password "secret" end
Cette factory est simple et va générer un utilisateur avec une adresse e-mail et un mot de passe uniques. Nous allons maintenant l'utiliser pour nos tests.
require 'spec_helper' describe "PasswordResets" do it "emails user when requesting password reset" user = Factory(:user) visit login_path click_link "password" fill_in "Email", :with => user.email click_button "Reset Password" end end
Ce test utilise notre factory pour créer un utilisateur et simule ensuite, grâce à quelques commandes Capybara, les étapes par lesquelles un utilisateur passerait pour réinitialiser son mot de passe. Nous visitons la page de connexion et cliquons sur le lien contenant 'mot de passe'. En ne définissant pas précisément le texte du lien, nos tests sont moins figés, si le texte du lien passe, par exemple, de 'Mot de passe oublié ?' à 'Vous avez oublié votre mot de passe ?', ils fonctionneront toujours. Sur la page à laquelle nous mène le lien, nous trouvons un champ texte associé à un label dont le texte contient 'Email' et saisissons comme valeur l'e-mail de l'utilisateur. Enfin, nous cliquons sur le bouton 'Réinitialiser mot de passe'.
Notre spec n'est pas encore terminée mais lorsque nous la sauvegardons, Guard va la lancer et nous allons voir notre premier échec.
1) PasswordResets emails user when requesting password reset Failure/Error: click_link "password" Capybara::ElementNotFound: no link with title, id or text 'password' found # (eval):2:in `click_link' # ./spec/requests/password_resets_spec.rb:7:in `block (2 levels) in <top (required)>'
La spec échoue car Capybara ne trouve pas le lien 'mot de passe'. Nous allons corriger ceci avant de continuer. Tout ce que nous devons faire, c'est ajouter le lien à la page de connexion.
<h1>Log in</h1> <%= form_tag sessions_path do %> <div class="field"> <%= label_tag :email %> <%= text_field_tag :email, params[:email] %> </div> <div class="field"> <%= label_tag :password %> <%= password_field_tag :password %> </div> <p><%= link_to "forgotten password?", new_password_reset_path %> <div class="actions"><%= submit_tag "Log in" %></div> <% end %>
Le lien pointe vers new_password_reset_path
mais, comme ce chemin n'est pas encore défini, Guard va nous donner une autre erreur lorsqu'il lancera la spec de nouveau.
1) PasswordResets emails user when requesting password reset Failure/Error: visit login_path ActionView::Template::Error: undefined local variable or method `new_password_reset_path' for #<#<Class:0x000001039349d8>:0x000001039269f0>
Cela montre les avantages de cette méthode de test. L'erreur suivante est montrée et sa correction ne devrait pas demander beaucoup de code. Nous allons générer un contrôleur PasswordResets
avec une action new
. Comme nous utilisons des specs de requête pour tester les couches contrôleur et vue, nous n'avons pas besoin des fichiers de spec du contrôleur et des vues. Nous pouvons dire au générateur de ne pas les créer en passant l'option --no-test-framework
.
$ rails g controller password_resets new --no-test-framework
Nous devons également modifier le fichier de routage pour faire de PasswordResets
une ressource.
Auth::Application.routes.draw do get "logout" => "sessions#destroy", :as => "logout" get "login" => "sessions#new", :as => "login" get "signup" => "users#new", :as => "signup" root :to => "home#index" resources :users resources :sessions resources :password_resets end
Lorsque Guard lance notre spec, il nous indique maintenant qu'il ne peut pas trouver le champ de texte email sur la page de réinitialisation de mot de passe.
1) PasswordResets emails user when requesting password reset Failure/Error: fill_in "Email", :with => user.email Capybara::ElementNotFound: cannot fill in, no text field, text area or password field with id, name, or label 'Email' found
Pour corriger cela, nous allons remplacer le code par défaut de la vue de réinitialisation de mot de passe par un formulaire contenant le champ et le bouton nécessaires.
<%= form_tag password_resets_path, :method => :post do %> <div class="field"> <%= label_tag :email %> <%= text_field_tag :email, params[:email] %> </div> <div class="actions"><%= submit_tag "Reset Password" %></div> <% end %>
L'erreur suivante est causée par le fait qu'il n'y ait pas d'action create
à laquelle le formulaire peut envoyer une requête POST.
1) PasswordResets emails user when requesting password reset Failure/Error: click_button "Reset Password" AbstractController::ActionNotFound: The action 'create' could not be found for PasswordResetsController
Nous allons créer cette action dans le contrôleur et faire en sorte qu'elle redirige vers la page d'accueil.
class PasswordResetsController < ApplicationController def new end def create redirect_to :root end end
Nous avons maintenant le nécessaire pour que notre spec passe.
Running: spec/controllers/password_resets_controller_spec.rb . Finished in 0.14507 seconds 1 example, 0 failures
Étendre notre spécification
Maintenant que notre spec passe, nous pouvons l'étendre. Nous voulons montrer une notification après que le bouton 'Réinitialiser mot de passe' ait été cliqué. Nous allons donc ajouter cela à la spec.
require 'spec_helper' describe "PasswordResets" do it "emails user when requesting password reset" do user = Factory(:user) visit login_path click_link "password" fill_in "Email", :with => user.email click_button "Reset Password" page.should have_content("Email sent") end end
Le test, bien sûr, échoue puisque nous n'avons pas encore écrit le code pour afficher le message. Nous allons modifier le contrôleur pour que cela soit fait.
class PasswordResetsController < ApplicationController def new end def create redirect_to :root, :notice => "Email sent with password reset instructions." end end
La spec passe de nouveau mais nous n'envoyons toujours pas l'e-mail. Nous pouvons le tester en utilisant ActionMailer::Base::deliveries
pour obtenir une liste des e-mails envoyés. Nous pouvons ensuite appeler last
sur cette liste pour récupérer le dernier e-mail envoyé. Nous le ferons souvent dans nos specs, nous allons donc créer un nouveau fichier dans /spec/support
et y écrire le code permettant de récupérer le dernier e-mail envoyé.
module MailerMacros def last_email ActionMailer::Base.deliveries.last end def reset_email ActionMailer::Base.deliveries = [] end end
Nous avons également écrit une méthode reset_email
que nous allons appeler au début de chaque spec. Elle va vider la liste de façon à ce que nous puissions commencer chaque spec dans un état connu.
De façon à ce que nous puissions utiliser ces nouvelles méthodes dans nos specs, nous allons appeler config.include
dans notre fichier spec_helper.rb
et inclure notre nouveau module.
# This file is copied to spec/ when you run 'rails generate rspec:install' ENV["RAILS_ENV"] ||= 'test' require File.expand_path("../../config/environment", __FILE__) require 'rspec/rails' require 'capybara/rspec' # Requires supporting ruby files with custom matchers and macros, etc, # in spec/support/ and its subdirectories. Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f} RSpec.configure do |config| config.mock_with :rspec config.use_transactional_fixtures = true config.include(MailerMacros) config.before(:each) { reset_email } end
Après avoir inclu notre macro, nous allons utiliser config.before(:each)
pour appeler reset_email
de façon à ce que la liste des e-mails envoyés soit vidée avant chaque lancement de spec.
Nous pouvons maintenant utiliser notre nouvelle méthode last_email
dans nos specs et vérifier que le dernier e-mail envoyé était à destination de l'utilisateur que nous venons de créer.
require 'spec_helper' describe "PasswordResets" do it "emails user when requesting password reset" do user = Factory(:user) visit login_path click_link "password" fill_in "Email", :with => user.email click_button "Reset Password" page.should have_content("Email sent") last_email.to.should include(user.email) end end
Ce test va évidemment échouer. Le dernier e-mail aura pour valeur nil
puisque aucun code n'a encore été écrit pour l'expédier. Nous allons générer un mailer de façon à pouvoir le faire.
$ rails g mailer user_mailer password_reset
Le générateur crée son propre fichier de spécification que nous regarderons un peu plus tard. Pour le moment, nous allons le commenter pour pouvoir nous concentrer sur notre spec en cours. Nous allons modifier le mailer de façon à ce qu'il envoie l'e-mail à l'adresse de l'utilisateur avec un sujet approprié.
class UserMailer < ActionMailer::Base default from: "from@example.com" def password_reset(user) @user = user mail :to => user.email, :subject => "Password Reset" end end
Dans notre PasswordResetsController
, nous allons changer l'action create
de façon à ce qu'elle trouve l'utilisateur par l'adresse e-mail saisie dans le formulaire et envoie l'e-mail.
def create user = User.find_by_email(params[:email]) UserMailer.password_reset(user).deliver redirect_to :root, :notice => "Email sent with password reset instructions." end
Notre spec passe à nouveau.
Gérer le token de réinitialisation de mot de passe
Bien que la spec passe, la fonctionnalité qu'elle définit est loin d'être complète. Nous devrions générer un token (jeton) de réinitialisation de mot de passe et l'ajouter dans l'e-mail. Ces détails n'ont pas besoin d'être placés dans la spécification de requête. Il est préférable de faire en sorte qu'elle définisse le flux général de la requête, dans notre cas, vérifier que l'utilisateur reçoit un e-mail de réinitialisation de mot de passe lorsqu'il le demande. Nous pouvons utiliser des tests plus bas niveau pour vérifier les détails.
Maintenant que nous avons une spec qui passe, c'est le moment de jeter un oeil sur notre code pour voir ce qui peut être pris du contrôleur et placé dans le modèle. Un bon exemple ici est la ligne de code dans PasswordResetsController
qui envoie l'e-mail. Nous pouvons la déplacer dans une nouvelle méthode send_password_reset
dans le modèle User
.
def create user = User.find_by_email(params[:email]) user.send_password_reset redirect_to :root, :notice => "Email sent with password reset instructions." end
class User < ActiveRecord::Base attr_accessible :email, :password, :password_confirmation has_secure_password validates_presence_of :password, :on => :create def send_password_reset UserMailer.password_reset(self).deliver end end
Nous allons maintenant vérifier que nos specs passent toujours. C'est le cas, nous pouvons donc continuer. Nous allons ensuite écrire plus de specs pour ajouter un peu de matière à notre modèle User
. Nous allons créer un fichier de specs dans /spec/models/user.rb
et y écrire quelques specs.
require 'spec_helper' describe User do describe "#send_password_reset" do let(:user) { Factory(:user) } it "generates a unique password_reset_token each time" do user.send_password_reset last_token = user.password_reset_token user.send_password_reset user.password_reset_token.should_not eq(last_token) end it "saves the time the password reset was sent" do user.send_password_reset user.reload.password_reset_sent_at.should be_present end it "delivers email to user" do user.send_password_reset last_email.to.should include (user.email) end end end
Nous voulons que la méthode send_password_reset
fasse trois choses lorsqu'elle est appelée. Elle devrait créer un token unique, enregistrer le moment auquel le token a été envoyé et expédier un e-mail à l'utilisateur. Elle effectue déjà la dernière tâche. Nous allons modifier la méthode afin qu'elle se charge également des deux autres. Notez qu'avant les specs, nous appelons let(:user)
. Cela assigne un nouvel utilisateur, issue de la factory, à user
avant chaque exécution de spec.
Deux des specs échouent pour le moment et cela est dû au fait que nous n'avons ni le champ password_reset_token
, ni le champ password_reset_sent_at
dans la table des utilisateurs en base.
$ rails g migration add_password_reset_to_users password_reset_token:string password_reset_sent_at:datetime
Avec les nouveaux champs en base, les specs échouent toujours mais pour d'autres raisons.
Failures: 1) User#send_password_reset generates a unique password_reset_token each time Failure/Error: user.password_reset_token.should_not eq(last_token) expected nil not to equal nil (compared using ==) # ./spec/models/user_spec.rb:11:in `block (3 levels) in <top (required)>' 2) User#send_password_reset saves the time the password reset was sent Failure/Error: user.reload.password_reset_sent_at.should be_present expected present? to return true, got false # ./spec/models/user_spec.rb:16:in `block (3 levels) in <top (required)>'
Les specs échouent maintenant parce que password_reset_token
et password_reset_sent_at
ne sont pas renseignés dans la méthode sent_password_reset
. Cela peut être corrigé en écrivant une méthode generate_token
qui va créer un token unique. Nous pouvons ensuite modifier sent_password_reset
afin qu'elle appelle generate_token
, donne une valeur à password_reset_sent_at
et sauvegarde l'utilisateur.
class User < ActiveRecord::Base attr_accessible :email, :password, :password_confirmation has_secure_password validates_presence_of :password, :on => :create def send_password_reset generate_token(:password_reset_token) self.password_reset_sent_at = Time.zone.now save! UserMailer.password_reset(self).deliver end def generate_token(column) begin self[column] = SecureRandom.urlsafe_base64 end while User.exists?(column => self[column]) end end
Nos specs passent toutes à nouveau.
Tester le Mailer
Maintenant que nos specs passent, nous allons retourner à celle de notre mailer, créée lors de la génération de ce dernier. Nous l'avions alors commentée. Nous allons devoir modifier le code par défaut de façon à pouvoir vérifier que notre mailer fonctionne correctement. Dans la spec, nous allons créer un nouvel utilisateur depuis la factory mais cette fois, nous allons renseigner le champ password_reset_token
de cet utilisateur. Nous allons ensuite changer la ligne créant l'e-mail afin que l'utilisateur soit passé en paramètre de l'appel à UserMailer.password_reset
.
La spec va vérifier que l'e-mail est envoyé à la bonne adresse et que le corps contient le lien vers le token de réinitialisation de l'utilisateur.
require "spec_helper" describe UserMailer do describe "password_reset" do let(:user) { Factory(:user, :password_reset_token => "anything") } let(:mail) { UserMailer.password_reset(user) } it "sends user password reset url" do mail.subject.should eq("Password Reset") mail.to.should eq([user.email]) mail.from.should eq(["from@example.com"]) end it "renders the body" do mail.body.encoded.should match(edit_password_reset_path(user.password_reset_token)) end end end
Notre spec échoue puisque le corps de l'e-mail ne contient pas le lien, ajoutons le donc.
To reset your password, click the URL below.
<%= edit_password_reset_url(@user.password_reset_token) %>
If you did not request your password to be reset just ignore this email and your password will continue to stay the same.
La spec échoue toujours car une option :host
est manquante pour l'envoie de l'e-mail. Nous pouvons la configurer dans le fichier de réglage de notre environnement de test en ajoutant la ligne suivante :
config.action_mailer.default_url_options = { :host => "www.example.com" }
Nous devons également configurer cette option dans nos environnements de développement et de production mais nous n'allons pas le faire maintenant.
Toutes nos specs passent maintenant. Au passage, si nous avions besoin de dire à Guard de relancer les specs, nous pourrions le faire grâce à CTRL+\
.
Tester d'autres scénarios
L'une des parties les plus difficiles du développement dirigé par les tests et de se lancer et d'établir un workflow. Une fois que vous avez commencé, il est facile de copier/coller les tests pour ajouter des variations et tester de nouvelles fonctionnalités. Par exemple, testons le cas où un utilisateur saisit une adresse e-mail erronée et demande une réinitialisation de mot de passe. Nous pouvons facilement copier la spec existante dans password_resets_spec.rb
et en créer une nouvelle pour tester cela.
it "does not email invalid user when requesting password reset" do visit login_path click_link "password" fill_in "Email", :with => "madeupuser@example.com" click_button "Reset Password" page.should have_content("Email sent") last_email.should be_nil end
La spec échoue puisque le code du contrôleur échoue si l'utilisateur n'est pas trouvé. Nous allons corriger cela.
def create user = User.find_by_email(params[:email]) user.send_password_reset if user? redirect_to :root, :notice => "Email sent with password reset instructions." end
Cela répond au test et toutes nos specs passent à nouveau.
Avec ce modèle de développement établi, il est facile d'aller dans la spécification de requête et d'ajouter de nouvelles fonctionnalités à la réinitialisation de mot de passe, par exemple, pour tester que le token n'a pas expiré ou qu'il est bien valide, etc. D'autres cas de tests sont disponibles dans le code source final de cet épisode sur la page Github de Ryan Bates.
C'est tout pour cet épisode sur le test du lien 'Mot de passe oublié'. Tester peut être un sujet controversé et différents points de vue sont émis quant à la meilleure façon de les écrire pour une application Rails. Le plus important est que vous testiez votre application, peu importe la méthode.