LoginSignup
1
0

More than 3 years have passed since last update.

Railsチュートリアル 第9章 発展的なログイン機構 - [Remember me] のテスト

Posted at

[Remember me] ボックスをテストする

ありがちなミス

うまく動かない実装

params[:session][:remember_me] ? remember(user) : forget(user)

このコードはうまく動きません。理由は以下です。

  1. Railsチュートリアルにおける実装上、param[:sessions][:remember_me]の値は、'0''1'になる
  2. Rubyの言語仕様上、falsenil以外は、真偽値として評価した場合に全て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メソッドを直接操作し、引数userid属性を、:user_idキーに対する値として一時cookiesに格納する

log_in_asという名前について、Railsチュートリアル本文においては、「既存のlog_inメソッド1との混乱を防ぐため、あえて別の名前を使った」という説明が与えられています。

実際にlog_in_asメソッドを定義する場所は、test/test_helper.rbActiveSupport::TestCaseクラス内となります。

test/test_helper.rb
  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というクラスそのものが存在しないはずなので、まずはクラスの定義から書かなければなりません。

test/test_helper.rb
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に追加していきます。

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メソッドの呼び出し
cookies[:remember_token]

正しく動かすためには、キーとして文字列を使う必要があります。

テスト内で正しく動く`cookies`メソッドの呼び出し
cookies['remember_token']

そういえば、HTTP的にもクエリパラメータは「文字列のキーと、それに対する文字列の値」でしたね。

UsersLoginTestに、[remember me] チェックボックスに対するテストを実装する

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']
+   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属性の実装は以下のようになっています。

app/models/user.rb
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コントローラーの内容を書き換えていきます。

app/controllers/sessions_controller.rb
  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の内容を書き換えていきます。

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内における、特定の分岐処理のコードブロックに対し、テストから漏れていることを確認する」という使い方をしています。具体的には以下のような記述です。

app/helpers/sessions_helper.rb
  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.rbSessionsHelperTestに書いていけばよい、ということになります。

>>> pwd
~/docker/rails_tutorial_test/sample_app
>>> touch test/helpers/sessions_helper_test.rb
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メソッドのみを実行する」ようにする必要があります。具体的には、以下の手順で実現可能です。

  1. fixtureを用いて、@user変数がUserモデルの正しい実体となるように与える
  2. 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を呼び出す前に、@userremember_digest属性を書き換えています。これにより、SessionsHelperTest#setupで生成された永続cookiesのremember_tokenに対するdigestと、@userremember_digestは一致しなくなります。

このような状況では、current_usernilを返さなければなりません。「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])以降のコードブロック編

app/helpers/sessions_helper.rb
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の場合編

app/helpers/sessions_helper.rb
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を削除した以下のソースが最終形となります。

app/helpers/sessions_helper.rb
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としてログインできてしまっている」という状態が発生する実装ですね。実際に発生したら明らかに顔面真っ青系の不具合です。これはテストで検知できなければいかません。

app/helpers/sessions_helper.rb
  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テストが失敗した、という結果が返ってきました。想定通りのテストが実装できているようです。


  1. より具体的には、SessionsHelper#log_inとなります。 

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0