[Remember me] ボックスをテストする
ありがちなミス
うまく動かない実装
params[:session][:remember_me] ? remember(user) : forget(user)
このコードはうまく動きません。理由は以下です。
- Railsチュートリアルにおける実装上、
param[:sessions][:remember_me]
の値は、'0'
か'1'
になる - Rubyの言語仕様上、
false
とnil
以外は、真偽値として評価した場合に全てtrue
として評価される
結果、このコードでは常にremember(user)
が実行されてしまいます。
正しい実装
この場合、正しい実装は以下になります。
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
テスト内でユーザーがログインするためのヘルパーメソッド
コントローラーの単体テスト編
post
メソッドに関するテストをしているわけではないのに、いちいちpost
メソッドを呼び出してログインするというのはいかにも迂遠です。単に「ログイン済みのユーザーが必要となるテストを行う」というだけであれば、post
メソッドをバイパスし、ログイン済みの状態から始めたいものです。
Railsチュートリアルでは、そうした要求を実現する実装手段の一つとして、「session
メソッドに有効なユーザーIDを直接与える」という方法を提示しています。コードは以下です。
def log_in_as(user)
session[:user_id] = user.id
end
具体的な動作は以下のようになります。
- Userモデルの有効なオブジェクトを引数
user
として取る -
session
メソッドを直接操作し、引数user
のid
属性を、:user_id
キーに対する値として一時cookiesに格納する
log_in_as
という名前について、Railsチュートリアル本文においては、「既存のlog_in
メソッド1との混乱を防ぐため、あえて別の名前を使った」という説明が与えられています。
実際にlog_in_as
メソッドを定義する場所は、test/test_helper.rb
のActiveSupport::TestCase
クラス内となります。
class ActiveSupport::TestCase
fixtures :all
include ApplicationHelper
# テストユーザーがログイン中の場合にtrueを返す
def is_logged_in?
!session[:user_id].nil?
end
+ # テストユーザーとしてログインする
+ def log_in_as(user)
+ session[:user_id] = user.id
+ end
end
統合テスト編
前提として、統合テストの追加ヘルパーメソッドは、test/test_helper.rb
内のActionDispatch::IntegrationTest
クラスで定義します。現時点ではActionDispatch::IntegrationTest
というクラスそのものが存在しないはずなので、まずはクラスの定義から書かなければなりません。
class ActionDispatch::IntegrationTest
end
統合テストでは、session
メソッドを直接扱うことはできません。sessions
にアクセスするためには、Sessionsリソースに対してpost
を送信する必要があります。ログインフォームで入力するのは「メールアドレス」「パスワード」「[remember me]チェックボックス(のチェック有無)」でしたね。
def log_in_as(user, password: 'password', remember_me: '1')
post login_path, params: { session: { email: user.email,
password: password,
remember_me: remember_me } }
end
こちらも名前はlog_in_as
とします。
「テストユーザーとしてログインする」という動作自体は、単体テストであっても統合テストであっても同じです。同じlog_in_as
という名前を使うことにより、単体テストであっても統合テストであっても、「ログイン済みの状態をテストしたいときはlog_in_as
メソッドを呼び出せばよい」という実装が実現できます。Railsチュートリアルの訳注では、「これもダックタイピングの一種と言えそう」と説明されています。
log_in_as
ヘルパーメソッドを追加する
上記2つのlog_in_as
ヘルパーメソッドは、ならびにActionDispatch::IntegrationTest
クラスは、test/test_helper.rb
に追加していきます。
ENV['RAILS_ENV'] ||= 'test'
...略
class ActiveSupport::TestCase
fixtures :all
include ApplicationHelper
# テストユーザーがログイン中の場合にtrueを返す
def is_logged_in?
!session[:user_id].nil?
end
+
+ # テストユーザーとしてログインする
+ def log_in_as(user)
+ session[:user_id] = user.id
+ end
+ end
+
+ class ActionDispatch::IntegrationTest
+ # テストユーザーとしてログインする
+ def log_in_as(user, password: 'password', remember_me: '1')
+ post login_path, params: { session: { email: user.email,
+ password: password,
+ remember_me: remember_me } }
+ end
end
実際のテストの実装
[remember me] チェックボックスのオンとオフ
[remember me] チェックボックスがオンの場合のテストは以下のようになります。
log_in_as(@user, remember_me: '1')
一方、[remember me] チェックボックスがオフの場合のテストは以下のようになります。
log_in_as(@user, remember_me: '0')
ユーザーが保存されたかどうか
ログイン成功後にcookies
内部のremember_token
キーを調べれば、ユーザーが保存されたかどうかをチェックすることができます。
(現在の設計では)cookiesの値がユーザーの記憶トークンと一致することを確認できない
コントローラー内のuser
変数には記憶トークンの属性が含まれています。しかしながら、remember_token
はRDBに保存されない属性であり、ゆえに@user
インスタンス変数にも含まれていません。
# rails console --sandbox
>> user = User.find(1)
>> pp(user)
#<User:0x00007f6424052528
id: 1,
name: "Rails Tutorial",
email: "example@railstutorial.org",
created_at: Tue, 22 Oct 2019 22:46:59 UTC +00:00,
updated_at: Tue, 22 Oct 2019 22:46:59 UTC +00:00,
password_digest:
"$2a$10$j21OfGX82PY0/BqDcapJmeeo/xaVKgSQ9pEZD8hAp4BoyO0PUV92K",
remember_digest: nil>
Railsコンソールで、Rubyのpp
メソッドを使って@user
インスタンス変数の値を出力した結果です。確かにremember_token
という属性は含まれていません。
テスト内ではcookies
メソッドにシンボルを与えても正しく動かない
Railsチュートリアルの著者が数時間詰まったという注意ポイントです。
cookies
メソッドは、ハッシュから値を取り出すのと同様にして使うことができますが、テスト内ではキーとしてシンボルを使うことができません。すなわち、以下のような呼び出し方では正しく動かないのです。
cookies[:remember_token]
正しく動かすためには、キーとして文字列を使う必要があります。
cookies['remember_token']
そういえば、HTTP的にもクエリパラメータは「文字列のキーと、それに対する文字列の値」でしたね。
UsersLoginTest
に、[remember me] チェックボックスに対するテストを実装する
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
def setup
@user = users(:rhakurei)
end
...略
+
+ test "login with remembering" do
+ log_in_as(@user, remember_me: '1')
+ assert_not_empty cookies['remember_token']
+ end
+
+ test "login without remembering" do
+ # クッキーを保存してログイン
+ log_in_as(@user, remember_me: '1')
+ delete logout_path
+ # クッキーを削除してログイン
+ log_in_as(@user, remember_me: '0')
+ assert_empty cookies['remember_token']
+ end
end
演習 - [Remember me] ボックスをテストする
1. リスト 9.27とリスト 9.28の不足分を埋め、[remember me] チェックボックスのテストを改良してみてください。
前提条件 - remember_token
の実装
現時点で、Userモデルにおけるremember_token
属性の実装は以下のようになっています。
class User < ApplicationRecord
attr_accessor :remember_token
# ...略
end
前提条件 - assigns
メソッド
テスト内部でassigns
メソッドを使うと、コントローラーで定義したインスタンス変数にテストの内部からアクセスできるようになります。
assigns
メソッドは、コントローラーで定義したインスタンス変数に対応する対応するシンボルを渡して使います。例えば、コントローラーのcreate
アクションで@user
というインスタンス変数が定義されている場合、テスト内部ではassigns(:user)
と書くことでインスタンス変数にアクセスできるわけです。
Rails 5.0 以降、assigns
メソッドは非推奨
実は、Rails 5.0 以降、assigns
メソッド(およびassert_template
メソッド)は非推奨とされ、Rails本体には含まれなくなりました。このあたりに言及すると脱線になるので、別記事を立てて言及しています。
本題
まずはSessionsコントローラーの内容を書き換えていきます。
class SessionsController < ApplicationController
...略
def create
- user = User.find_by(email: params[:session][:email].downcase)
+ @user = User.find_by(email: params[:session][:email].downcase)
- if user && user.authenticate(params[:session][:password])
+ if @user && @user.authenticate(params[:session][:password])
- log_in user
- params[:session][:remember_me] == '1' ? remember(user) : forget(user)
- redirect_to user
+ log_in @user
+ params[:session][:remember_me] == '1' ? remember(@user) : forget(@user)
+ redirect_to @user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
end
end
...略
end
続いて、test/integration/users_login_test.rb
の内容を書き換えていきます。
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
def setup
@user = users(:rhakurei)
end
...略
test "login with remembering" do
log_in_as(@user, remember_me: '1')
- assert_not_empty cookies['remember_token']
+ assert_equal cookies['remember_token'], assigns(:user).remember_token
end
test "login without remembering" do
# クッキーを保存してログイン
log_in_as(@user, remember_me: '1')
delete logout_path
# クッキーを削除してログイン
log_in_as(@user, remember_me: '0')
assert_empty cookies['remember_token']
end
end
以上の変更を保存した上で、改めてテストを実行してみます。
# rails test
Running via Spring preloader in process 249
Started with run options --seed 26276
27/27: [=================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.98370s
27 tests, 72 assertions, 0 failures, 0 errors, 0 skips
テストが無事成功しました。
[Remember me] をテストする
「テストが失敗しなければテストコードに問題がある」というようにソースコードを変更する
「既存のソースコードに対し、テストから漏れているコードブロックがあるかもしれない」という場合に有効な手段です。
Railsチュートリアルの本文では、「current_user
内における、特定の分岐処理のコードブロックに対し、テストから漏れていることを確認する」という使い方をしています。具体的には以下のような記述です。
module SessionsHelper
...略
# 現在ログイン中のユーザーを返す(いる場合)
def current_user
if (user_id = session[:user_id])
@current_user ||= User.find_by(id: user_id)
elsif (user_id = cookies.signed[:user_id])
+ raise # テストがパスすれば、この部分がテストされていないことがわかる
user = User.find_by(id: user_id)
if user && user.authenticated?(cookies[:remember_token])
log_in user
@current_user = user
end
end
end
...略
end
raise
を書いたコードブロックがテストから漏れていなければ、テストは例外をキャッチして失敗するはずです。しかし…
# rails test
Running via Spring preloader in process 262
Started with run options --seed 7773
27/27: [=================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.77232s
27 tests, 72 assertions, 0 failures, 0 errors, 0 skips
なんということでしょう。テストが成功してしまったではないですか。これで「テストコードに問題がある」ことが明らかになりました。
Sessionsヘルパーに対してテストを実装する
Sessionsヘルパーの実体は、app/helpers/sessions_helper.rb
に書かれたSessionsHelper
モジュールです。Railsのルール上、当該モジュールに対するテストは、test/helpers/sessions_helper_test.rb
のSessionsHelperTest
に書いていけばよい、ということになります。
>>> pwd
~/docker/rails_tutorial_test/sample_app
>>> touch test/helpers/sessions_helper_test.rb
require 'test_helper'
class SessionsHelperTest < ActionView::TestCase
end
ここまでのやり方は、以前に作成したtest/helpers/application_helper_test.rb
と同様です。
Sessionsヘルパーに必要なテストの内容
一時cookiesが存在せず、永続cookiesだけが存在する状況を実現する
新たに必要になるテストは、「一時cookiesが存在せず、永続cookiesだけが存在する場合」に対応するテストです。このような状況を実現するためには、「log_in
メソッドを実行せずにremember
メソッドのみを実行する」ようにする必要があります。具体的には、以下の手順で実現可能です。
- fixtureを用いて、
@user
変数がUserモデルの正しい実体となるように与える -
log_in
メソッドを実行せずに、渡されたユーザーをremember
メソッドで記憶する
そのような状況を実現するために必要なsetup
メソッドの定義は、以下のようになります。
def setup
@user = users(:rhakurei)
remember(@user)
end
users(:rhakurei)
というのは、Userモデルの正しい実体となるように定義されたユーザー情報の組です。定義はtest/fixtures/users.yml
に存在します。
一時cookiesが存在せず、永続cookiesだけが存在する場合に対応するテスト
上記の状況が実現できたところで実装する必要があるテストの内容は以下です。
-
current_user
の戻り値が@user
と等しくなること -
current_user
が呼び出された後、一時cookiesにIDが保存されること-
current_user
内でlog_in
がきちんと呼び出されていればOK、となるはずです
-
具体的には、test/helpers/sessions_helper_test.rb
内において、「current user returns right user when session is nil」という名前をつけた上で、以下のように定義しています。
test "current user returns right user when session is nil" do
assert_equal @user, current_user
assert is_logged_in?
end
ユーザーは存在するものの、remember_digest
の内容が誤っている場合に対応するテスト
ソースコードで言えば、以下のような状況になる場合に対応するテストです。
user && user.authenticated?(cookies[:remember_token])
# =>false
上記の状況を実現するため、current_user
を呼び出す前に、@user
のremember_digest
属性を書き換えています。これにより、SessionsHelperTest#setup
で生成された永続cookiesのremember_token
に対するdigestと、@user
のremember_digest
は一致しなくなります。
このような状況では、current_user
はnil
を返さなければなりません。「nil
であること」を確認するためには、assert_nil
というテストメソッドを使います。
test "current user returns nil when remember digest is wrong" do
@user.update_attribute(:remember_digest, User.digest(User.new_token))
assert_nil current_user
end
実際にテストを失敗させてみる
elsif (user_id = cookies.signed[:user_id])
以降のコードブロック編
module SessionsHelper
# ...略
# 現在ログイン中のユーザーを返す(いる場合)
def current_user
if (user_id = session[:user_id])
# ...略
elsif (user_id = cookies.signed[:user_id])
raise
# ...略
end
end
# ...略
end
上述のソースコードにおいては、「current user returns right user when session is nil」というテストがエラーを出して失敗するはずです。早速やってみましょう。
# rails test
Running via Spring preloader in process 314
Started with run options --seed 54800
ERROR["test_current_user_returns_right_user_when_session_is_nil", SessionsHelperTest, 2.6886899000019184]
test_current_user_returns_right_user_when_session_is_nil#SessionsHelperTest (2.69s)
RuntimeError: RuntimeError:
app/helpers/sessions_helper.rb:20:in `current_user'
test/helpers/sessions_helper_test.rb:11:in `block in <class:SessionsHelperTest>'
ERROR["test_current_user_returns_nil_when_remember_digest_is_wrong", SessionsHelperTest, 2.742209299998649]
test_current_user_returns_nil_when_remember_digest_is_wrong#SessionsHelperTest (2.74s)
RuntimeError: RuntimeError:
app/helpers/sessions_helper.rb:20:in `current_user'
test/helpers/sessions_helper_test.rb:17:in `block in <class:SessionsHelperTest>'
29/29: [=================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.75387s
29 tests, 72 assertions, 0 failures, 2 errors, 0 skips
確かにtest_current_user_returns_right_user_when_session_is_nil
の時点でRuntimeError
となっていますね。コードブロックに対するテストが期待通り存在することがわかります。
なお、「current user returns nil when remember digest is wrong」というテストの方でもこのコードブロックを通るので、同様にRuntimeError
が発生します。
user && user.authenticated?(cookies[:remember_token])
がfalse
の場合編
module SessionsHelper
# ...略
# 現在ログイン中のユーザーを返す(いる場合)
def current_user
if (user_id = session[:user_id])
# ...略
elsif (user_id = cookies.signed[:user_id])
user = User.find_by(id: user_id)
if user && user.authenticated?(cookies[:remember_token])
# ...略
else
raise
end
end
end
end
上述のソースコードにおいては、「current user returns nil when remember digest is wrong」というテストのみがエラーを出して失敗するはずです。早速やってみましょう。
# rails test
Running via Spring preloader in process 340
Started with run options --seed 49535
ERROR["test_current_user_returns_nil_when_remember_digest_is_wrong", SessionsHelperTest, 2.176088100000925]
test_current_user_returns_nil_when_remember_digest_is_wrong#SessionsHelperTest (2.18s)
RuntimeError: RuntimeError:
app/helpers/sessions_helper.rb:25:in `current_user'
test/helpers/sessions_helper_test.rb:17:in `block in <class:SessionsHelperTest>'
29/29: [=================================] 100% Time: 00:00:03, Time: 00:00:03
Finished in 3.28492s
29 tests, 74 assertions, 0 failures, 1 errors, 0 skips
確かにtest_current_user_returns_nil_when_remember_digest_is_wrong
の時点で、かつ同テストのみがRuntimeError
となっていますね。コードブロックに対するテストが期待通り存在することがわかります。
すべてのraise
を削除した上で再度テスト
すべてのraise
を削除した以下のソースが最終形となります。
module SessionsHelper
# ...略
# 現在ログイン中のユーザーを返す(いる場合)
def current_user
if (user_id = session[:user_id])
@current_user ||= User.find_by(id: user_id)
elsif (user_id = cookies.signed[:user_id])
user = User.find_by(id: user_id)
if user && user.authenticated?(cookies[:remember_token])
log_in user
@current_user = user
end
end
end
# ...略
end
この状態でテストを実行すれば、問題なく成功するはずです。早速やってみましょう。
# rails test
Running via Spring preloader in process 353
Started with run options --seed 15029
29/29: [=================================] 100% Time: 00:00:03, Time: 00:00:03
Finished in 3.78530s
29 tests, 75 assertions, 0 failures, 0 errors, 0 skips
無事テストは成功しました。
演習 - [Remember me] をテストする
1. リスト 9.33にあるauthenticated?
の式を削除すると、リスト 9.31の2つ目のテストで失敗することを確かめてみましょう (このテストが正しい対象をテストしていることを確認してみましょう)。
「remember_token
にでたらめな値が入っていて、当然そのDigestがremember_digest
と一致しないのに、user
としてログインできてしまっている」という状態が発生する実装ですね。実際に発生したら明らかに顔面真っ青系の不具合です。これはテストで検知できなければいかません。
module SessionsHelper
...略
# 現在ログイン中のユーザーを返す(いる場合)
def current_user
if (user_id = session[:user_id])
@current_user ||= User.find_by(id: user_id)
elsif (user_id = cookies.signed[:user_id])
user = User.find_by(id: user_id)
- if user && user.authenticated?(cookies[:remember_token])
+ if user
log_in user
@current_user = user
end
end
end
...略
end
上記のソースコードを保存してrails test
を実行してみます。
# rails test
Running via Spring preloader in process 366
Started with run options --seed 43965
FAIL["test_current_user_returns_nil_when_remember_digest_is_wrong", SessionsHelperTest, 3.0472236999994493]
test_current_user_returns_nil_when_remember_digest_is_wrong#SessionsHelperTest (3.05s)
Expected #<User id: 959740715, name: "Reimu Hakurei", email: "rhakurei@example.com", created_at: "2019-11-14 00:26:03", updated_at: "2019-11-14 00:26:05", password_digest: "$2a$04$Ug0CkSUZkMbLb0xN2Lbfl.zD16IbCn8eK.q/FPggq7j...", remember_digest: "$2a$04$zLExBw.Yl4NEZPlVNmg8SuPEQPQ.76GrCe9SAHhV0yN..."> to be nil.
test/helpers/sessions_helper_test.rb:17:in `block in <class:SessionsHelperTest>'
29/29: [=================================] 100% Time: 00:00:03, Time: 00:00:03
Finished in 3.12188s
29 tests, 75 assertions, 1 failures, 0 errors, 0 skips
test_current_user_returns_nil_when_remember_digest_is_wrong
テストが失敗した、という結果が返ってきました。想定通りのテストが実装できているようです。
-
より具体的には、
SessionsHelper#log_in
となります。 ↩