#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)
今後のエピソードでは、テストの話題をより多く取り上げていきたいと思います。今回は、前回のエピソード[動画を見る, 読む]でログインフォームに追加した「パスワード忘れ」のリンク用のテストを書くとしたらどうなるかを見ていきます。
前回のエピソードの最初では、ログインフォームを持つアプリケーションがありました。フォームには基本的な認証機能がありましたが「ログイン状態を記憶(remember me)」のチェックボックスと「パスワード忘れ(forgotten password)」のリンクがなかったので、順を追って追加していきました。今回、リンクを再度追加する作業を、テスト駆動開発(TDD)の手法を用いて行います。
前回は、アプリケーションのテストは、コードを書きながら適宜ブラウザで確認をしていました。今回はブラウザは閉じたままで機能をテストするためのコードを書き、ユーザ体験に特に注目しなくてはいけないときだけブラウザを開くことにします。
テストを書くことを簡単にするために、アプリケーションにテスト関連のgemを追加する必要があります。Rails 3.1を使用していますが、ここで行うことはすべてRails 3.0でも同じように動作するはずです。Gemfileの最後のtest groupの部分にgemを追加します。
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
ここではRSpecを使用していますが、どのテスト用フレームワークを使ってもかまいません。注意する必要があるのは、他のテスト関連のgemと違い、RSpecはRakeタスクを正しく動作させるためにdevelopment groupにも入れる必要があります。またfixtureの代わりにFactory Girlを、WebブラウザによるユーザとのやりとりをシミュレートするためにCapybaraを、テストの自動実行のためにGuardを、それぞれ採用しました。これらのgemはそれぞれ以前のエピソードで取り上げられています。Factory Girlはエピソード158[動画を見る, 読む]、Capybaraはエピソード257 [動画を見る, 読む]、Guardはエピソード264 [動画を見る, 読む]で紹介しました。
gemをインストールするためにbundle
を実行します。インストールができたらRSpecを設定するために次のコマンドを実行します。
$ rails g rspec:install
ここで/spec
ディレクトリの下にいくつかのディレクトリを作成します。サポートファイルを置くためのsupport
ディレクトリ、Guardの動作に必要なmodels
ディレクトリとrouting
ディレクトリです。
$ mkdir spec/support spec/models spec/routing
ついでにここでGuardのinitializerを実行します。
$ guard init rspec
rb-fsevent
gemもインストールしてGuardがファイルの変更を検知できるようにします。インストールができたら、新規ターミナルタブでGuardを起動して、バックグラウンドで実行したままにしておきます。
$ 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.
RSpec ジェネレータを実行すると、/spec/spec_helper.rb
にファイルが作成されました。このファイルに、Capybaraを有効化するために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...
fixtureを使わないため、ファイルのコメントのアドバイスに従って、fixtureのパスの行を削除します。
# Remove this line if you're not using ActiveRecord or ↵ ActiveRecord fixtures config.fixture_path = "#{::Rails.root}/spec/fixtures"
最初のテスト
テストを開始する準備ができたので、まずpassword_reset
と名付けた統合テストから始めます。
$ rails g integration_test password_reset
ここでRSpecのジェネレータが起動して、request specというものを作成してくれます。specのデフォルトのコードは次のようになっています。
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
デフォルトのspecを削除して、自分で書いたspecと置き換えます。ユーザがパスワードのリセットをリクエストしたときにEメールが送信されるかどうかをテストします。このためには作業の対象とするUser
レコードが必要です。 登録ページを介してユーザを登録する方法もありますが、テストの対象だけに注力するために、ファクトリからユーザを作成します。テストを始める前に、/spec
ディレクトリのfactories.rb
ファイルへの追記によって、User
ファクトリを作成します。この名前と場所の意味は、ここで定義するファクトリはすべて自動的にFactory Girlに認識されるということです。
Factory.define :user do |f| f.sequence(:email) { |n| "foo#{n}@example.com" } f.password "secret" end
このファクトリは単純で、ユーザをユニークなEメールアドレスとパスワードと共に生成します。これを今回のテストで使用します。
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
このテストは作成したファクトリを使用してユーザを作成し、パスワードをリセットするときにユーザがとるであろう手順を、いくつかのCapybaraのコマンドを使ってシミュレートします。ログインページにアクセスし、「password」という単語を含むリンクをクリックします。リンクのテキストを厳密に定義しないことで、テストをより失敗しにくいようにします。テキストが例えば「パスワード忘れ」から「パスワードを忘れましたか?」に変わっても、テストは変わらず成功します。リンク先のページで、テキストフィールドのうち関連づけられたラベルのテキストに「Email」が含まれるものを探して、ユーザのメールアドレスを入力します。最後に「Reset Password」ボタンをクリックします。
specはまだ完成ではないですが、保存をするとGuardがそれを実行して、初めての失敗が表示されます。
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)>'
Capybaraが“password”リンクを見つけられなかったため、specが失敗しました。これを修正して作業を継続します。必要な作業は、ログインページにリンクを追加することです。
<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 %>
リンク先をnew_password_reset_path
としていますが、そのパスはまだ定義されていないので、Guardが再度specを実行して再度エラーを表示します。
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>
この作業の流れは、テストに対するこのアプローチの利点をよく示しています。常に次のエラーが表示されることで、小さなコードの変更だけで修正を進めていくことができます。この問題を修正するために、PasswordResets
コントローラをnew
アクションと一緒に作成します。コントローラとビュー層をテストするのにrequest specを使っているので、 controllerとviewのspecファイルは必要ありません。ジェネレータにそれらを作成しないように指示するために--no-test-framework
オプションを渡します。
$ rails g controller password_resets new --no-test-framework
合わせてルートファイルを修正して、PasswordResets
をリソースにします。
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
ここでGuardが実行されると、パスワードリセットのページにEメールのテキストフィールドが見つからないというエラーが表示されます。
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
これを修正するために、パスワードリセットのビューのデフォルトのコードを、条件に合ったテキストフィールドとボタンを持ったフォームに置き換えます。
<%= 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 %>
次に表示されるエラーは、フォームがPOSTする先のcreate
アクションが存在しないというものです。
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
このアクションをコントローラ内に作成して、トップページにリダイレクトするようにします。
class PasswordResetsController < ApplicationController def new end def create redirect_to :root end end
これでspecが成功しました。
Running: spec/controllers/password_resets_controller_spec.rb . Finished in 0.14507 seconds 1 example, 0 failures
specを拡張する
specが成功したので、今度はそれを拡張していきます。「Reset Password(パスワードをリセット)」ボタンを押した後にフラッシュメッセージを表示したいので、それを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
メッセージを表示するコードをまだ書いていないので、もちろんこれは失敗します。メッセージを表示するようコントローラを修正します。
class PasswordResetsController < ApplicationController def new end def create redirect_to :root, :notice => "Email sent with password reset instructions." end end
specは成功しますが、実際にはEメールを送信していません。これをテストするためには、ActionMailer::Base::deliveries
を使って送信したEメールのリストを取得します。そしてそのリストに対してlast
を呼び出すことで最後に送信したEメールを取得します。これはspec内の他のところでも使われるので、/spec/support
ディレクトリに新規ファイルを作成し、最新のEメールを取得するコードを書きます。
module MailerMacros def last_email ActionMailer::Base.deliveries.last end def reset_email ActionMailer::Base.deliveries = [] end end
合わせてreset_email
メソッドを書いて、各specの最初に呼び出すようにします。これはリストを空にして、各specを常に規定の状態から開始できるようにします。
specからこれらの新しいメソッドを使用できるように、spec_helper
ファイルのconfig.include
を呼び出して新たに作成したモジュールをincludeします。
# 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
マクロをincludeした後、config.before(:each)
を使ってreset_email
を呼び出し、送信されたEメールのリストを空にしてから各specが実行されるようになります。
それではspecで新しく作成したlast_email
メソッドを使用し、最後に送信されたEメールが、我々が作成したユーザに送信されたものかどうかをチェックします。
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
これはもちろん失敗します。Eメールを送信するコードをまだ書いていないので、last_email
はnil
になります。メールを送信するためのメーラを作成します。
$ rails g mailer user_mailer password_reset
ジェネレータがそれ自身のspecファイルを作成するので、後ほどその内容を見てみます。今はその部分をコメントアウトして、現在のspecに集中することにします。メーラを修正して、ユーザのEメールアドレスにメールを送信し、適切なタイトルを設定します。
class UserMailer < ActionMailer::Base default from: "from@example.com" def password_reset(user) @user = user mail :to => user.email, :subject => "Password Reset" end end
PasswordResetsController
のcreate
アクションを修正して、User
をフォームから入力されたEメールアドレスで検索してEメールを送信するようにします。
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
今後はspecが成功しました。
パスワードリセット用トークンを扱う
specは成功しますが、機能はまだ完成ではありません。パスワードリセット用のトークンを生成してEメールに含まなくてはいけません。これらの詳細は必ずしもrequest spec内にある必要はありません。specは単純にしてrequestの全体的な流れを定義させるのがいいでしょう。今回の場合だと、ユーザがパスワードリセットをリクエストしたときにメールを受け取るかどうかまでをチェックします。詳細のテストは低レベルテストで行うことにします。
specが成功したので、ここでコード全体を見渡してコントローラからモデルに移せる部分があるかどうか見てみましょう。ここでのいい例は、PasswordResetsController
の中のEメールを送信する部分のコード行です。これをUser
モデルに新しく作成したsend_password_reset
メソッドに移すことができます。
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
ここで、specがまだ成功するかどうか確認します。成功したので、このまま続けます。次にUser
モデルにいくつかspecを追加して肉付けします。/spec/models/user.rb
にspecファイルを作成し、そこにspecを追加していきます。
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
send_password_reset
メソッドが呼び出されたら3つのことが起きることが期待されます。一意的なパスワードリセット用のトークンを作成し、トークンが送られた時間を保存し、ユーザにEメールを送信します。これらの内の最後の一つはすでにできているので、メソッドを修正して残りの2つを実装します。specの前にlet(:user)
を呼び出すことに注意してください。これは、各specが実行される前にファクトリからの新規ユーザにuser
を割り当てます。
specの内の2つは現在失敗していますが、この理由はusersテーブルにpassword_reset_token
とpassword_reset_sent_at
の各フィールドがまだないからです。次のmigrationを実行してデータベースのマイグレーションを行うことで、この問題を解決できます。
$ rails g migration add_password_reset_to_users password_reset_token:string password_reset_sent_at:datetime
データベースに新しくフィールドを追加しましたが、specは今度は別の理由で失敗します。
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)>'
今度はpassword_reset_token
とpassword_reset_sent_at
の各フィールドがsent_password_reset
メソッドに設定されていないという理由で、specが失敗します。これは、一意のトークンを生成するgenerate_token
メソッドを書くことで修正されます。そしてsent_password_reset
を修正してgenerate_token
を呼び出し、password_reset_sent_at
の時間を設定してユーザを保存します。
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
すべてのspecが成功しました。
メーラをテストする
specが成功したので、メーラを作成したときに自動生成されながらコメントアウトしていたmailer specに戻ります。デフォルトのコードを修正して、メーラが正しく動作するかをテストできるようにします。specではファクトリから新規ユーザを作成しますが、今回はそのユーザにpassword_reset_token
を設定します。それからメールを作成する行を変更して、UserMailer.password_reset
の呼び出しにそのユーザを渡します。
Eメールが正しいメールアドレスに送信されて、本文にそのユーザのパスワードリセット用のトークンへの正しいリンクが含まれていることを、specがチェックします。
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
Eメールの本文に正しいリンクが含まれていないのでspecが失敗します。ではそれを追加しましょう。
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.
Eメールを送信するのに必要な:host
が設定されていないため、specsはまた失敗します。これを、テスト環境の設定ファイルに以下の行を追加することで設定します。
config.action_mailer.default_url_options = { :host => "www.example.com" }
この値をdevelopmentとproduction環境にも設定する必要がありますが、ここでは省略します。
これですべてのspecが成功しました。参考情報として、Guardにspecを手動で再実行させるには、CTRL+\
を使います。
他のシナリオをテストする
テスト駆動開発のもっとも難しい部分は、実際に手をつけて作業の流れを作るまでです。ひとたび流れをつかんだら、コピー&ペーストでテストのバリエーションを追加して機能をテストしていくのは簡単です。例えば、ユーザが不正なメールアドレスを入力する場合とパスワードのリセットをリクエストする場合をテストしてみましょう。password_resets_spec.rb
中にすでにあるspecをコピーして新しいspecを作成してこれをテストすることができます。
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
一致するユーザが見つからない場合、コントローラのコードが失敗するため、specも失敗します。これを直してみましょう。
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
これでテストケースの条件が満たされるので、すべてのspecsが再度すべて成功します。
このテストのパターンが確立されれば、このrequest specを見直してパスワードのリセットのための追加機能を付加するのも簡単です。例えば、パスワードリセット用のトークンが期限切れになっていないかをテストしたり、渡されるトークンが不正だった場合をテストしたりなどが可能です。Ryan BatesのGithubサイトにあるこのエピソード用の一番最後のソースコードに、その他のテストケースが入っています。
「パスワード忘れ」リンクのテストに関する今回のエピソードは以上で終わりです。テストは議論の対象となりやすいテーマであり、Railsアプリケーション用のテストを書くベストな方法がどれなのかについては人によって意見が分かれるところです。もっとも重要なのは、どのような方法でもいいので、とにかくテストを行うということです。