#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)
A partir de este episodio nos vamos a dedicar con más frecuencia al tema de la escritura de tests. Esta vez vamos a ver cómo habríamos escrito los enlaces del episodio anterior [verlo, leerlo], en concreto la funcionalidad de recuperación de contraseñas que añadíamos al formulario de inicio de sesión.
Cuando empezamos con el episodio anterior ya teníamos una aplicación con un formulario de inicio de sesión. El formulario ya tenía la autenticación básica pero no implementaba la funcionalidad de inicio de sesión automático o recuperación de contraseña, que fue lo que añadimos. Volveremos esta vez a añadir este enlace de recuperación pero mediante un enfoque dirigido por tests (TDD).
En el episodio anterior fuimos probando la aplicación con el navegador según íbamos desarrollándola. Esta vez vamos a cerrar el navegador y vamos a escribir el código para probar la funcionalidad abriendo el navegador sólo cuando tengamos que centrarnos en la experiencia de usuario.
Para poder escribir los tests vamos a añadir algunas de las gemas relacionadas con tests al Gemfile
de nuestra aplicación. Como estamos usando Rails 3.1, todo lo que hagamos aquí debería funcionar con Rails 3.0. Añadamos las gemas al final del archivo en el grupo de pruebas.
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
Aunque podríamos usar cualquier framework de pruebas, nosotros preferimos usar RSpec. Al contrario que las otras gemas relacionadas con los tests RSpec queda incluido en el grupo de desarrollo para poder ejecutar sus tareas de Rake. También hemos preferido utilizar Factory Girl en lugar de fixturas, Capybara para simular la interacción del usuario con un navegador y Guard para ejecutar los tests automáticamente. Hemos visto todas estas gemas en episodios anteriores. Factory Girl en el episodio 158 [verlo, leerlo], Capybara en el 257 [verlo, leerlo] y Guard en el 264 [verlo, leerlo].
Podemos instalar las gemas ejecutando bundle
. Una vez que se hayan instalado configuraremos RSpec ejecutando
$ rails g rspec:install
RSpec creará algunos directorio debajo del directorio /spec
, un directorio support
para los ficheros de apoyo, otro llamado models
y otro más llamado routing
que hace falta para Guard.
$ mkdir spec/support spec/models spec/routing
Este es un buen momento para lanzar la inicialización de Guard.
$ guard init rspec
Como estamos trabajando en Mac OS X, también nos interesa instalar la gema rb-fsevent
para que Guard pueda detectar los cambios en los archivos. Una vez que quede instalado lanzaremos Guard en una pestaña del terminal y lo dejaremos en segundo plano.
$ 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.
Cuando ejecutamos anteriormente el generador de RSpec, éste creó un fichero llamado /spec/spec_helper.rb
. Tenemos que habilitar Capybara en este archivo, añadiendo require 'capybara/rspec'
.
# 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...
También deberemos seguir el consejo que nos dan los comentarios del fichero y eliminaremos la línea que establece la ruta de la fixturas porque no las vamos a usar.
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures config.fixture_path = "#{::Rails.root}/spec/fixtures"
Nuestro primer test
Ya estamos listos para empezar a escribir los tests, empezaremos con un test de integración que llamaremos password_reset
.
$ rails g integration_test password_reset
El generador de RSpec creará lo que llama una especificación de petición. El código por defecto de la especificación tiene el siguiente aspecto:
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
Vamos a eliminar la especificación generada por defecto y vamos a poner la nuestra. Comprobaremos que se envía un correo a un usuario cuando solicitan la recuperación de su contraseña. Para ello nos hace falta un registro User
sobre el que trabajar. Podríamos ir a la página de registro y generar así un usuario, pero es mejor centrarnos exactamente en lo que queremos probar por lo que crearemos el usuario a partir de una factoría. Crearemos la factoría de User
antes de empezar a escribir nuestro test, poniéndola en el fichero factories.rb
de la carpeta /spec
. El nombre sugiere que todas las factorías que definamos aquí serán automáticamente cargadas por Factory Girl.
Factory.define :user do |f| f.sequence(:email) { |n| "foo#{n}@example.com" } f.password "secret" end
La factoría es muy sencilla y generará un usuario con una dirección de correo único y una contraseña para que podamos usarlo en nuestro test.
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
El cuerpo del test utiliza la factoría para crear un usuario y luego simula los pasos que el usuario tendría que tomar para solicitar la recuperación de contraseña, utilizando varias órdenes de Capybara. Se visita la página de inicio de sesión, y luego se hace clic en el enlace que contiene la palabra “password”, esto es una forma menos rígida de escribir el test de forma que si el texto cambia de “Forgotten password” a “Forgot your password?” el test seguirá pasando. En la página a la que nos lleva el enlace encontramos un campo de texto con una etiqueta asociada cuyo texto contiene “Email” y lo rellena con la dirección de correo del usuario. Por último tenemos que hacer clic en el botón “Reset Password” .
Nuestra especificación todavía no está terminada, y cuando la guardamos Guard la ejecutará y veremos el primer error.
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 especificación ha fallado porque Capybara no ha podido encontrar el enlace “password” . Corrijamos esto antes de continuar, lo único que tenemos que hacer es añadir el enlace en la página de inicio de sesión.
<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 %>
El enlace lleva a new_password_reset_path
, pero como todavía no hemos definido la ruta Guard nos dará otro error cuando vuelva a ejecutar la especificación.
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>
Aquí podemos apreciar la ventaja del desarrollo basado en tests. Siempre vemos el siguiente error y para corregirlo sólo debería ser necesario un pequeño cambio en el código. En este caso generaremos un controlador PasswordResets
con la acción <ocde>new</ocde>. Como estamos usando especificaciones de petición para probar el controlador y las vistas no nos hacen falta los archivos de especificación propiamente dichos, le podemos decir al generador que se los salte pasándole la opción --no-test-framework
.
$ rails g controller password_resets new --no-test-framework
Tenemos también que modificar el fichero de rutas para que PasswordResets
sea un recurso.
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
Cuando Guard ejecute la especificación nos dirá que no puede encontrar el campo de texto del email en la página de recuperación de contraseña.
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
Cambiemos para esto el código por defecto en la vista de recuperación de clave con un formulario que tenga los campos y botones apropiados.
<%= 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 %>
El siguiente error que vamos a ver se debe a que no hay acción create
a la que enviar el formulario.
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
Creemos esta accion en el controlador y hagamos que redirija a la página principal.
class PasswordResetsController < ApplicationController def new end def create redirect_to :root end end
Con esto ya debería pasar el test.
Running: spec/controllers/password_resets_controller_spec.rb . Finished in 0.14507 seconds 1 example, 0 failures
Ampliando nuestros tests
Ahora que pasa la especificación ya podemos ampliarla. Queremos mostrar un mensaje flash después de que se envíe el botón de “Reset Password”, por lo que lo añadiremos a la especificación.
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
Por supuesto esto falla porque todavía no hemos escrito el código para mostrar el mensaje. Modifiquemos el controlador para que lo haga.
class PasswordResetsController < ApplicationController def new end def create redirect_to :root, :notice => "Email sent with password reset instructions." end end
Con esto la especificación ya pasa aunque realmente no estemos enviando correo alguno. Podemos hacer un test utilizando ActionMailer::Base::deliveries
para recuperar el listado de los correos enviados y luego invocar last
para ver el último. Como haremos esto con frecuencia en nuestros tests vamos a crear un nuevo fichero en /spec/support/
para escribir el código necesario para recuperar el último correo enviado.
module MailerMacros def last_email ActionMailer::Base.deliveries.last end def reset_email ActionMailer::Base.deliveries = [] end end
También hemos escrito un método reset_email
al que invocaremos cada vez que empecemos un test. Con esto se borrará la lista para que el test empiece en un estado claramente definido.
Para poder usar estos nuevos métodos en nuestros tests tenemos que invocar a config.include
en spec_helper
, para incluir este nuevo módulo.
# 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
Tras incluir esta macro usaremos config.before(:each)
para invocar a reset_email
de forma que se borre la lista de correos enviados antes de cada test.
Ya podemos usar nuestro nuevo método last_email
en nuestra especificación, y comprobar que el último correo enviado fue enviado a nuestro usuario.
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
Por supuesto esto fallará, porque last_email
será nulo ya que no hemos escrito el código necesario para enviar el correo. Generemos el mailer necesario.
$ rails g mailer user_mailer password_reset
El generador escribe su propio fichero de especificación que veremos más adelante. Por ahora lo comentaremos todo para poder concentrarnos en nuestra propia especificación. Modificaremos el mailer para que envíe el correo a la dirección del usuario y establezca el asunto apropiado.
class UserMailer < ActionMailer::Base default from: "from@example.com" def password_reset(user) @user = user mail :to => user.email, :subject => "Password Reset" end end
En PasswordResetsController
modificaremos la acción create
para que encuentre al User
por la dirección de correo introducida en el formulario y envíe el correo.
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
Con esto la especificación ya pasa.
Gestión del token de recuperación de contraseña
La funcionalidad dista mucho de estar completa, a pesar de que todas nuestras especificaciones pasan. Deberíamos generar un token de reinicio de contraseña e incluirlo en el correo. Estos detalles no tienen por qué estar dentro de la especificación, es mejor que todo quede más sencillo y sólo definir el flujo general de la petición, en este caso comprobaremos que el usuario recibe un correo de recuperación de contraseña cuando lo solicitan. Podemos escribir más adelante tests de más bajo nivel para verificar los detalles.
Ahora que tenemos un test que pasa es buen momento de mirar el código para ver qué podemos llevar del controlador al modelo. Un buen ejemplo es la línea de código de PasswordResetsController
que envía el correo. Podemos llevarla a un método llamado send_password_reset
del modelo 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
Llegados a este punto debemos comprobar que los tests siguen pasando, tras lo cual continuamos. Lo siguiente que haremos será escribir más tests para afinar el modelo User
. Crearemos un fichero de especificaciones en /spec/models/user.rb
.
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
Queremos que el método send_password_reset
haga tres cosas. Debería crear un token único de recuperación de contraseña, guardar la hora en la que se ha generado el token y enviar un correo al usuario. Ya hace lo último; así que modificaremos el método para que haga las otras dos cosas. Nótese que antes de las especificaciones invocamos a let(:user)
. Esto asigna user
a un nuevo usuario de la factoria antes de la ejecución de cada test.
Dos de las especificaciones fallan porque no tenemos password_reset_token
ni password_reset_sent_at
en la tabla de usuarios. Así que ejecutaremos la migración.
$ rails g migration add_password_reset_to_users password_reset_token:string password_reset_sent_at:datetime
Con la nueva base de datos se seguirá produciendo un error, pero por otro motivo.
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)>'
El fallo ahora se debe a que password_reset_token
y password_reset_sent_at
no reciben ningún valor en el método sent_password_reset
, lo que puede corregirse escribiendo un nuevo método generate_token
que genera un token único, y luego modificar send_password_reset
para que invoque a generate_token
, establezca la hora de password_reset_sent_at
y luego guarde el usuario.
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
Ya vuelven a pasar los tests.
Tests del correo
Ahora que ya pasan los tests volveremos a la especificación del mailer que se generó en su momento y que dejamos comentada. Tendremos que modificar el código por defecto para poder probar que nuestro mailer funciona correctamente. En la especifiación creamos un nuevo usuario a partir de la factoría pero esta vez le estableceremos un password_reset_token
para el usuario. Luego modificaremos la línea que crea el correo de forma que se pase el usuario en la llamada a <ocde>UserMailer.password_reset</ocde>.
El test comprobará que se envía el correo a la dirección correcta y que el cuerpo contiene el enlace correcto con el token de recuperación de contraseña del usuario.
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
Nuestra especificación falla porque el cuerpo del correo no contiene el enlace correcto. Añadámoslo.
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.
Las especificaciones siguen fallando porque falta la opción :host
para enviar el correo. Podemos añadir la siguiente línea en nuestra configuración de tests:
config.action_mailer.default_url_options = { :host => "www.example.com" }
Más adelante tendremos que poner este valor en nuestros entornos de desarrollo y producción pero de momento no lo haremos.
Ya vuelven a pasar todos los tests y por cierto, si alguna vez tenemos que decirle a Guard que vuelva a ejecutar todos los tests podemos hacerlo con CTRL+\
.
Otros escenarios de test
Lo más difícil en el desarrollo dirigido por tests es echarse a andar y acostumbrarse a tener un flujo de trabajo. Una vez que estemos en marcha es fácil copiar y pegar tests para añadir las variaciones necesarias para probar el resto de la funcionalidad. Por ejemplo supongamos que tenemos el caso en que un usunario introduce una dirección de correo incorrecta y solicita una recuperación de contraseña. Podemos copiar muy fácilmente la especificación existente en password_resets_spec.rb
para crear una nueva que compruebe exactamente ese escenario.
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
El test falla porque el código del controlador falla si no encuentra un usuario. Corrijámoslo.
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
Con esto se satisface el caso de test y ya vuelve a pasar toda la suite.
Una vez que tengamos establecidos estos patrones de test es fácil añadir la funcionalidad para restablecer la clave por ejemplo comprobando que el token no ha expirado, o casos de prueba en el que el token recibido es incorrecto, etcétera. En el código fuente disponible para este episodio que está en la página de Github de Ryan Bates hay más tests escritos.
Con esto terminamos este episodio en el que hemos escrito tests que comprueban el buen funcionamiento de la recuperación de contraseñas. La escritura de tests puede llegar a ser algo controvertida porque hay diversas opiniones sobre la mejor manera de escribir tests en aplicaciones Rails. Lo más importante de todo es que, independientemente del método que escojamos, escribamos tests en nuestras aplicaciones.