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

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

## ありがちなミス

### うまく動かない実装

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

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

1. Railsチュートリアルにおける実装上、`param[:sessions][:remember_me]`の値は、`'0'`か`'1'`になる
2. Rubyの言語仕様上、`false`と`nil`以外は、真偽値として評価した場合に全て`true`として評価される

結果、このコードでは常に`remember(user)`が実行されてしまいます。

### 正しい実装

この場合、正しい実装は以下になります。

```ruby:
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
```

## テスト内でユーザーがログインするためのヘルパーメソッド

### コントローラーの単体テスト編

`post`メソッドに関するテストをしているわけではないのに、いちいち`post`メソッドを呼び出してログインするというのはいかにも迂遠です。単に「ログイン済みのユーザーが必要となるテストを行う」というだけであれば、`post`メソッドをバイパスし、ログイン済みの状態から始めたいものです。

Railsチュートリアルでは、そうした要求を実現する実装手段の一つとして、「`session`メソッドに有効なユーザーIDを直接与える」という方法を提示しています。コードは以下です。

```ruby:
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`メソッド[^既存のlog_inメソッド]との混乱を防ぐため、あえて別の名前を使った」という説明が与えられています。

[^既存のlog_inメソッド]: より具体的には、`SessionsHelper#log_in`となります。

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

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

```ruby:test/test_helper.rb
class ActionDispatch::IntegrationTest
  
end
```

統合テストでは、`session`メソッドを直接扱うことはできません。`sessions`にアクセスするためには、Sessionsリソースに対して`post`を送信する必要があります。ログインフォームで入力するのは「メールアドレス」「パスワード」「[remember me]チェックボックス（のチェック有無）」でしたね。

```ruby:
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`に追加していきます。

```diff: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] チェックボックスがオンの場合のテストは以下のようになります。

```ruby:
log_in_as(@user, remember_me: '1')
```

一方、[remember me] チェックボックスがオフの場合のテストは以下のようになります。

```ruby:
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`メソッドは、ハッシュから値を取り出すのと同様にして使うことができますが、テスト内ではキーとしてシンボルを使うことができません。すなわち、以下のような呼び出し方では正しく動かないのです。

```ruby:テスト内で正しく動かないcookiesメソッドの呼び出し
cookies[:remember_token]
```

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

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

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

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

```diff: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](https://railstutorial.jp/chapters/advanced_login?version=5.1#code-login_create_user_instance)と[リスト 9.28](https://railstutorial.jp/chapters/advanced_login?version=5.1#code-improved_remember_me_test)の不足分を埋め、[remember me] チェックボックスのテストを改良してみてください。

### 前提条件 - `remember_token`の実装

現時点で、Userモデルにおける`remember_token`属性の実装は以下のようになっています。

```ruby: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本体には含まれなくなりました。このあたりに言及すると脱線になるので、別記事を立てて言及しています。

* [Railsチュートリアル 第9章\[余談\] - Rails 5.0 以降、assigns（およびassert_template）は非推奨 - Qiita](https://qiita.com/rapidliner00/items/3e4647e78913b511f6bd)

### 本題

まずはSessionsコントローラーの内容を書き換えていきます。

```diff: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`の内容を書き換えていきます。

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

```diff: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.rb`の`SessionsHelperTest`に書いていけばよい、ということになります。

```
>>> pwd
~/docker/rails_tutorial_test/sample_app
>>> touch test/helpers/sessions_helper_test.rb
```

```ruby: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`メソッドの定義は、以下のようになります。

```ruby:
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」という名前をつけた上で、以下のように定義しています。

```ruby:
test "current user returns right user when session is nil" do
  assert_equal @user, current_user
  assert is_logged_in?
end
```

### ユーザーは存在するものの、`remember_digest`の内容が誤っている場合に対応するテスト

ソースコードで言えば、以下のような状況になる場合に対応するテストです。

```ruby:
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`というテストメソッドを使います。

```ruby:
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])`以降のコードブロック編

```ruby: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`の場合編

```ruby: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`を削除した以下のソースが最終形となります。

```ruby: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](https://railstutorial.jp/chapters/advanced_login?version=5.1#code-branch_no_raise)にある`authenticated?`の式を削除すると、[リスト 9.31](https://railstutorial.jp/chapters/advanced_login?version=5.1#code-persistent_sessions_test)の２つ目のテストで失敗することを確かめてみましょう (このテストが正しい対象をテストしていることを確認してみましょう)。

「`remember_token`にでたらめな値が入っていて、当然そのDigestが`remember_digest`と一致しないのに、`user`としてログインできてしまっている」という状態が発生する実装ですね。実際に発生したら明らかに顔面真っ青系の不具合です。これはテストで検知できなければいかません。

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