275: 私のテスト手法
Other formats:
今後のエピソードでは、テストの話題をより多く取り上げていきたいと思います。今回は、前回のエピソード[動画を見る, 読む]でログインフォームに追加した「パスワード忘れ」のリンク用のテストを書くとしたらどうなるかを見ていきます。
前回のエピソードの最初では、ログインフォームを持つアプリケーションがありました。フォームには基本的な認証機能がありましたが「ログイン状態を記憶(remember me)」のチェックボックスと「パスワード忘れ(forgotten password)」のリンクがなかったので、順を追って追加していきました。今回、リンクを再度追加する作業を、テスト駆動開発(TDD)の手法を用いて行います。
前回は、アプリケーションのテストは、コードを書きながら適宜ブラウザで確認をしていました。今回はブラウザは閉じたままで機能をテストするためのコードを書き、ユーザ体験に特に注目しなくてはいけないときだけブラウザを開くことにします。
テストを書くことを簡単にするために、アプリケーションにテスト関連のgemを追加する必要があります。Rails 3.1を使用していますが、ここで行うことはすべてRails 3.0でも同じように動作するはずです。Gemfileの最後のtest groupの部分にgemを追加します。
/Gemfile
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開発をOS X上で行っている場合は、
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'という行を追加します。
/spec/spec_helper.rb
# 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のパスの行を削除します。
/spec/spec_helper.rb
# 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のデフォルトのコードは次のようになっています。
/spec/requests/password_resets_spec.rb
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に認識されるということです。
/spec/factories.rb
Factory.define :user do |f|
f.sequence(:email) { |n| "foo#{n}@example.com" }
f.password "secret"
end
このファクトリは単純で、ユーザをユニークなEメールアドレスとパスワードと共に生成します。これを今回のテストで使用します。
/spec/requests/password_resets_spec.rb
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が失敗しました。これを修正して作業を継続します。必要な作業は、ログインページにリンクを追加することです。
/app/views/sessions/new.html.erb
<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をリソースにします。
/config/routes.rb
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
これを修正するために、パスワードリセットのビューのデフォルトのコードを、条件に合ったテキストフィールドとボタンを持ったフォームに置き換えます。
/app/views/password_resets/new.html.erb
<%= 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
このアクションをコントローラ内に作成して、トップページにリダイレクトするようにします。
/app/controllers/password_resets_controller.rb
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に追加します。
/spec/requests/password_resets_spec.rb
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
メッセージを表示するコードをまだ書いていないので、もちろんこれは失敗します。メッセージを表示するようコントローラを修正します。
/app/controllers/password_resets.rb
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メールを取得するコードを書きます。
/spec/support/mailer_macros.rb
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します。
/spec/spec_helper.rb
# 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メールが、我々が作成したユーザに送信されたものかどうかをチェックします。
/spec/requests/password_resets_spec.rb
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メールアドレスにメールを送信し、適切なタイトルを設定します。
/app/mailers/user_mailer.rb
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メールを送信するようにします。
/app/controllers/password_resets_controller.rb
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メソッドに移すことができます。
/app/controllers/password_resets_controller.rb
def create user = User.find_by_email(params[:email]) user.send_password_reset redirect_to :root, :notice => "Email sent with password ↵ reset instructions." end
/app/models/user.rb
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を追加していきます。
/spec/models/user_spec.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
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の時間を設定してユーザを保存します。
/app/models/user.rb
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がチェックします。
/spec/mailers/user_mailer_spec.rb
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が失敗します。ではそれを追加しましょう。
/app/views/user_mailer/password_reset.text.erb
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/environments/test.rb
config.action_mailer.default_url_options = { :host => "www.example.com" }
この値をdevelopmentとproduction環境にも設定する必要がありますが、ここでは省略します。
これですべてのspecが成功しました。参考情報として、Guardにspecを手動で再実行させるには、CTRL+\を使います。
他のシナリオをテストする
テスト駆動開発のもっとも難しい部分は、実際に手をつけて作業の流れを作るまでです。ひとたび流れをつかんだら、コピー&ペーストでテストのバリエーションを追加して機能をテストしていくのは簡単です。例えば、ユーザが不正なメールアドレスを入力する場合とパスワードのリセットをリクエストする場合をテストしてみましょう。password_resets_spec.rb中にすでにあるspecをコピーして新しいspecを作成してこれをテストすることができます。
/spec/requests/password_resets_spec.rb
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も失敗します。これを直してみましょう。
/app/controllers/password_resets_controller.rb
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アプリケーション用のテストを書くベストな方法がどれなのかについては人によって意見が分かれるところです。もっとも重要なのは、どのような方法でもいいので、とにかくテストを行うということです。



