LoginSignup
1
0

More than 3 years have passed since last update.

Railsチュートリアル 第8章 基本的なログイン機構 - ログイン

Posted at

何を実装するか

今までに、「ログインフォームで無効な値が送信された際の処理」については実装できました。次に実装するのは以下の動作です。

  • 有効なメールアドレスとパスワードの組でログインできるようにする
  • cookiesを使った一時セッションでログイン状態を保持できるようにする
    • 有効期限はブラウザウィンドウが閉じられるまでである
  • 実際にログイン中のユーザーのユーザー情報を活用する

Sessionヘルパーモジュール

  • 実体はapp/helpers/sessions_helper.rbである
  • rails generate controller sessionsにより自動生成される
  • 全てのビューに自動的に読み込まれる
  • 全てのコントローラで同モジュールを使えるようにするには、app/controllers/application_controller.rbincludeすればOK
    • セッションに関する機能は多くのコントローラで使うので、この処理の有用性は高い
app/controllers/application_controller.rb
  class ApplicationController < ActionController::Base
    protect_from_forgery with: :exception
+   include SessionsHelper
  end

log_inメソッド

sessionメソッド

Railsのメソッドとしてのsession1は、以下の機能を持ったメソッドです。

  • ユーザーのWebブラウザ内の一時Cookieに情報を格納する
  • ユーザーのWebブラウザ内の一時Cookieから情報を取り出す

sessionsメソッドはハッシュのように使うことができます。例えば、「ユーザーのWebブラウザ内に暗号化済みのユーザーIDを格納する」という処理を行う場合、以下のように記述します。すると、以後session[:user_id]でユーザーIDを元通りに取り出すことができるようになるのです。

session[:user_id] = user.id

Sessionsコントローラとは、名前が似ていますが無関係です。

log_inメソッドを実装する

場所はapp/helpers/sessions_helper.rbです。

app/helpers/sessions_helper.rb
  module SessionsHelper
+
+   # 渡されたユーザーでログインする
+   def log_in(user)
+     session[:user_id] = user.id
+   end
  end

このようなメソッドを実装することにより、同じログイン手法を様々な場所で使い回せるようになります。

一時cookiesの特徴

Railsのsessionメソッドにより作成された一時cookiesには、以下のような特徴があります。

  • 自動的に暗号化される
  • 攻撃者が一時cookiesの情報を盗み出せても、それを使って本物のユーザーとしてログインすることはできない

後者の特徴は一時セッションの大きな特長であり、永続的なcookiesにはあてはまりません。ゆえに、永続的なcookiesには、常に「セッションハイジャック」という攻撃を受ける可能性がつきまといます。

ユーザーにログインする機構の実装

SessionsHelperモジュールにlog_inメソッドを実装したので、この時点で「ログイン成功時の動作」を定義することができるようになりました。実際の動作は以下のようになります。

  1. ユーザーログイン動作を行い、セッションのcreateアクションを完了する
  2. ユーザーのプロフィールページにリダイレクトする

実際のコードは以下のようになります。場所はapp/controllers/sessions_controller.rbです。

app/controllers/sessions_controller.rb
  class SessionsController < ApplicationController
    ...略

    def create
      user = User.find_by(email: params[:session][:email].downcase)
      if user && user.authenticate(params[:session][:password])
-       # TODO: ユーザーログイン後にユーザー情報のページにリダイレクトする
+       log_in user
+       redirect_to user
      else
        ...略
      end
    end

    ...略
  end

POSTリクエストが正常に完了する」という場面なので、最後はリダイレクトで処理を終了しています(HTTPのステータスコードは302 Foundが返されます)。

redirect_to user

ユーザープロフィールへのリダイレクトです。「ユーザーを新規に作成する」という動作を実装したとき、POSTリクエストの最終結果として書いたコードと同じですね。Railsでは、HTTPのステータスコードが302 Foundになるようにしつつ、以下のようなプロフィールページへのルーティングを行います。

user_url(user)

これから実装が必要になる機能

現状では、ログインしても何も画面表示に変化はありません。ユーザーがログイン中かどうかは、ブラウザセッションを直接確認しなければわからない、という状況です。セッションという概念を誰もが知っているわけではなく、セッションを直接確認するもの手間がかかるので、これでは一般ユーザーは困りますよね。

というわけで、続いて実装が必要になるのは、「セッションから現在ログインしているユーザーIDを取り出して、画面で表示する」という機能です。

演習 - log_inメソッド

1. 有効なユーザーで実際にログインし、ブラウザからcookiesの情報を調べてみてください。このとき、sessionの値はどうなっているでしょうか?

ヒント: ブラウザでcookiesを調べる方法が分からない? 今こそググってみるときです! (コラム 1.1)

Firefoxの場合、開発ツールのストレージインスペクターからcookiesの情報を調べることができます。

スクリーンショット 2019-10-31 8.07.38.png

「値」は以下のようになっています。

_sample_app_session:"aFpnVExtVnJMbCtqMHFlQnBETTJjbW9HNFRuUWdTTG9hZ0U3eG9PMFVpQjkraDNOMjcrVVM3SHJxcXgzOGViZnpzUWRlWGxGYzZ6WGwyZysxNnZYeUZnd2dFR093eXUwOUQvMmo5UmY2R0dBdVhJclVpZG55VFlIVUdZcGIyNWFOSDhBeWFrQVY4bjNFTGpUdjg0VUxRYnMzcmNWWkhMWUxXWVo1Mk9Ja0cwPS0tRGJTd012Z3l1ZFVVYUF1bUE0MXg4dz09--897f7a9ec30539c63426ccb0775e306d04909f2d"

sessionの値は、ランダムな英数字の羅列となっています。これでは、ユーザーID等の情報を割り出すことは不可能でしょう。

なお、Chromeのディベロッパーツールにも類似の機能が含まれています。@kapiecii氏による、firefoxとchromeの標準機能でcookieの内容を確認する方法というQiita記事が参考になりました。

2. 先ほどの演習課題と同様に、Expiresの値について調べてみてください。

調べる場所は、1.と同じく、[Firefox - 開発ツール - ストレージインスペクター]です。以下のようにありました。

Expires:"セッション"

現在のユーザー

ここまでの実装で、ユーザーIDを一時セッションの中に安全に格納できるようになりました。今度は一時セッションの中に格納したユーザーIDの情報を別のページで取り出せるようにします。

実現する機能

以下の埋め込みRubyコードにより、現在ログイン中のユーザーのHomeページを表示できるようにします。

<%= current_user.home %>

また、以下のような形で、ユーザーのプロフィールページにリダイレクトできるようにします。

redirect_to current_user

一時セッションからユーザー情報を取り出すには

うまくいかない方法

User.find(session[:user_id])

findメソッドではうまくいきません。何が問題かというと、「ユーザーIDが存在しない状態でfindメソッドを呼び出すと、例外が投げられる」という動作です。

「ユーザーIDが存在しなければ例外が投げられる」という動作。プロフィールページであれば確かに妥当な動作です。しかしながら、「ユーザーがログインしていない状態で埋め込みRubyが表示される」などのユースケースが考えられる今回のケースでは、正常なページ遷移の中でsession[:user_id]の値がnilとなる状況も想定されます。「正常なページ遷移の中で例外が投げられてしまう」のは困ります。

うまくいく方法

User.find_by(id: session[:user_id])

find_byメソッドであれば、session[:user_id]の値がnilであったとしても、例外は発生しません。この場合、find_byメソッドの戻り値がnilとなるだけで、正常なページ遷移から逸脱することはありません。

Userオブジェクトを検索するキーはidとなります。

find_by!メソッドではないです。こちらは「存在しない値を検索対象とした場合、例外が投げられる」というメソッドです。

実装を定義してみる

current_userメソッドの実装を定義してみます。

def current_user
  if session[:user_id]
    User.find_by(id: session[:user_id])
  end
end

セッションにユーザーIDが存在しない場合、このメソッドは直ちに終了してnilを返します。この動作は、「RDBへの無駄なアクセスを減らす」という意味で有用です。「RDBへの無駄なアクセスを減らす」という文面をさらに丹念に検討していくと、以下のような理由が考えられます。

  • ユーザーIDがnilの状態でRDBにアクセスするのは、無駄なアクセスになることが明らかである
    • RDBにおけるテーブル設計から、ユーザーIDがnilであるような有効なデータは存在し得ない
  • RDBへのアクセスは必要最小限に留めるべきである
    • メモリ内のリソースへのアクセスに比べてオーバーヘッドが大きい

メモ化 - User.find_byの実行結果をインスタンス変数に格納する

User.find_byの実行結果は、@current_userというインスタンス変数に格納することとします。こうした手法はメモ化(Wikipediaにおけるメモ化の解説)と呼ばれています。今回のサンプルアプリケーションにおいては、「1リクエスト内におけるRDBへの問い合わせを1度限りにする」という意義があります。

メソッド全体の動作としては以下のようになります。

  • @current_usernilであれば、User.find_byの実行結果を@current_userというインスタンス変数に格納する
  • @current_usernilでなければ、@current_userを返す

上述の動作を逐次的に書いていくと、以下のようなRubyコードになります。

if @current_user.nil?
  @current_user = User.find_by(id: session[:user_id])
else
  @current_user
end

メモ化コードのさらなる簡略化

しかしながら、Rubyにおいては、前述のコードをさらに簡略化する技法が存在します。or演算子||がそれです。

@current_user = @current_user || User.find_by(id: session[:user_id])

「Rubyにおいては、オブジェクトを真偽値として評価した場合にfalseとなるのはfalsenilのみである。それ以外の全てのオブジェクトは、真偽値として評価するとtrueになる」というのがポイントですね。User.find_byの戻り値は、(戻り値がnilでなければ)真偽値として評価した場合、常にtrueとなります。

結果、「@current_usernilの場合のみ、User.find_byが実行される」という動作が実現されることになります。

上述コードは、自己代入演算子||=を用いてさらに簡略化することができます。

@current_user ||= User.find_by(id: session[:user_id])

自己代入演算子||=については、Ruby独特の代入演算子 ||= の意味について説明してみたにてより突っ込んだ解説を書いてみました(それ以上に突っ込んだ仕様については、同記事における @scivola さんのコメントも参考になります)。

一時セッションからユーザー情報を取り出すコードの実装

以上を踏まえた上で、app/helpers/sessions_helper.rbに実装を追加してみます。

app/helpers/sessions_helper.rb
  module SessionsHelper

    # 渡されたユーザーでログインする
    def log_in(user)
      session[:user_id] = user.id
    end

+   # 現在ログイン中のユーザーを返す(いる場合)
+   def current_user
+     if session[:user_id]
+       @current_user ||= User.find_by(id: session[:user_id])
+     end
+   end
  end

演習 - 現在のユーザー

1. Railsコンソールを使って、User.find_by(id: ...)で対応するユーザーが検索に引っかからなかったとき、nilを返すことを確認してみましょう。

「ありえないid」ということで、id: 0として検索してみます。

# rails console --sandbox
>> User.find_by(id: 0)
  User Load (2.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 0], ["LIMIT", 1]]
=> nil

無事nilが返ってきました。

2. 先ほどと同様に、今度は:user_idキーを持つsessionハッシュを作成してみましょう。リスト 8.17に記したステップに従って、||=演算子がうまく動くことも確認してみましょう。

Railsチュートリアルのリスト8.17の内容
> session = {}
> session[:user_id] = nil
> @current_user ||= User.find_by(id: session[:user_id])
<ココに何が表示されるか?>
> session[:user_id]= User.first.id
> @current_user ||= User.find_by(id: session[:user_id])
<ココに何が表示されるか?>
> @current_user ||= User.find_by(id: session[:user_id])
<ココに何が表示されるか?>
>> session = {}

>> session[:user_id] = nil

>> @current_user ||= User.find_by(id: session[:user_id])
  User Load (0.4ms)  SELECT  "users".* FROM "users" WHERE "users"."id" IS NULL LIMIT ?  [["LIMIT", 1]]
=> nil  # nilが返ってきた

>> session[:user_id] = User.first.id
  User Load (0.2ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> 1  # idが返ってきた。値は1

>> @current_user ||= User.find_by(id: session[:user_id])
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, ...略>  # id が 1 である有効なUserオブジェクトが返ってきた

>> @current_user ||= User.find_by(id: session[:user_id])
=> #<User id: 1, ...略>  # id が 1 である有効なUserオブジェクトが返ってきた

レイアウトリンクを変更する

ログイン機能やセッション情報の実際の活用例として、まずは「ユーザーがログインしているときとそうでないときでレイアウトを変更する」という機能を実装してみます。ユーザーがログインしているときのレイアウトデザインのモックアップは、図 8.7で示したようにします。

テストを書くタイミングについて

理想的には、「モックアップが示された段階で統合テストを書く」という形にしたいものです。しかしながら、「Railsチュートリアル1周目に臨んでいる人」のスキルセットを考えた場合、ここでいきなり統合テストを書けるほどのスキルの蓄積があるというのは考えにくいものです。「レイアウトリンクを変更する」というセクションでも、未知の技術要素は少なからず出てくるでしょう。

というわけで、Railsチュートリアル本文でも、今しばらくは以下のようなスタイルを取ることになります。

  • 未知の技術要素を学習しながら、実際の実装を書いていく
  • 実装を書いてから、テストの書き方を学習しつつテストを書く

重要なのは「学習しながら」というところですね。

ログインしているときとログインしていないときで処理を分ける

「ERBコードの中でif-else構文を使用し、条件に応じて表示するリンクを使い分ける」という実装方法が自然であろう、そう考えられます。コード化すると以下のような感じです。

<% if logged_in? %>
  # ログインユーザー用のリンク
<% else %>
  # ログインしていないユーザー用のリンク
<% end %>

まず、logged_in?というメソッドが必要になるのがわかります。

logged_in?ヘルパーメソッド

「ユーザーがログインしている」ということの当サンプルアプリケーションにおける意味は、「sessionにユーザーidが存在している」ということになります。これは「current_usernilでない」ことと同義になります。このようなチェックは、否定演算子!を使って行います。

実装場所はapp/helpers/sessions_helper.rbとなります。

app/helpers/sessions_helper.rb
  module SessionsHelper

    # 渡されたユーザーでログインする
    def log_in(user)
      session[:user_id] = user.id
    end

    # 現在ログイン中のユーザーを返す(いる場合)
    def current_user
      if session[:user_id]
        @current_user ||= User.find_by(id: session[:user_id])
      end
    end

+   # ユーザーがログインしていればtrue、その他ならfalseを返す
+   def logged_in?
+     !current_user.nil?
+   end
  end

ログイン済みユーザーのページに用いるリンク

ログイン済みユーザーのページには、以下の4つのリンクを実装します。

  • Users
  • Profile
  • Settings
  • Log out

現時点で実装せず、仮とするリンク

このうち、UsersとSettingsについては、現時点では実装せず、仮リンクのみを設置することにします。

<%= link_to "Users", '#' %>
<%= link_to "Settings", '#' %>

ログアウト用リンク

ログアウト用リンクでは、config/routes.rbで定義したlogout_pathを使用します。

<%= link_to "Log out", logout_path, method: :delete %>

ログアウト用リンクにおいては、link_toメソッドの第3引数にオプションハッシュを渡していることがポイントです。methodというキーに対し、:deleteというシンボルを値として渡しています。このオプションは、使用するHTTPのリクエストとしてDELETEを用いるという意味です。

余談 - HTTPのDELETEメソッドが送られるまでに

なお、歴史的経緯などにより、2019年11月頭現在のHTMLの仕様では、HTMLのフォームからPUTDELETEといったリクエストを送出することはできません。link_to:methodオプションの値に:deleteを設定してから、実際にDELETEリクエストが送出されるに至るまでには、裏でRailsの仕組みが色々と動いているのです。この辺を丹念に解説していくと、それだけでQiitaの記事が1本書けるほどのボリュームになります。

プロフィール用のリンク

<%= link_to "Profile", current_user %>

このコードは、以下のように書くこともできます。

<%= link_to "Profile", user_path(current_user) %>

Railsにより、current_useruser_path(current_user)に自動的に変換されます。

Railsチュートリアルがcurrent_userという書き方を採用したのは、「コード全体としてバランスを良くするために、同じような書き方をしているコードについて括弧の数を揃える」といった理由であると推測します。

ログイン用のリンク

ユーザーがログインしていない場合のページには、config/routes.rbで定義したlogin_pathを使用して、ログインフォームへのリンクを作成します。

<%= link_to "Log in", login_path %>

レイアウトリンクをページ内容ヘッダー部に適用する

編集対象のファイルはapp/views/layouts/_header.html.erbです。

app/views/layouts/_header.html.erb
<header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app",  root_path, id: "logo" %>
    <nav>
      <ul class="nav navbar-nav navbar-right">
        <li><%= link_to "Home", root_path %></li>
        <li><%= link_to "Help", help_path %></li>
        <% if logged_in? %>
          <li><%= link_to "Users", '#' %></li>
          <li class="dropdown">
            <a href="#" class="dropdown-toggle" data-toggle="dropdown">
              Account<b class="caret"></b>
            </a>
            <ul class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li>
              <li><%= link_to "Settings", '#' %></li>
              <li class="divider"></li>
              <li>
                <%= link_to "Log out", logout_path, method: :delete %>
              </li>
            </ul>
          </li>
        <% else %>
          <li><%= link_to "Log in", login_path %></li>
        <% end %>
      </ul>
    </nav>
  </div>
</header>

Bootstrapのドロップダウンメニュー機能を有効にする

ここまでで、dropdowndropdown-menuといったクラスが使われていることに気がついたかもしれません。これらは、Bootstrapのドロップダウンメニュー機能で使われるクラス名です。

しかしながら、現段階ではBootstrapのドロップダウンメニュー機能を使うことはできません。必要なJavaScriptライブラリが足りないためです。実際に追加で必要となるのは以下のライブラリです。

  • Bootstrapに同梱されているJavaScriptライブラリ
  • JQuery

いずれもJavaScriptライブラリですね。これらのライブラリを読み込むようにするためには、アセットパイプラインで読み込みを指示する必要があります。コードを追加する箇所は、app/assets/javascripts/application.jsとなります。

app/assets/javascripts/application.js
    //= require rails-ujs
 +  //= require jquery
 +  //= require bootstrap
    //= require turbolinks
    //= require_tree .

ログインできるようになった!

ここまでのコードの追加で、以下の機能の実装が完了しました。

  • ログインパスにアクセスする
  • 有効なユーザーとしてログインする

結果、アプリケーションの動作はどのように変化したでしょうか。いくつかスクリーンショットを掲載します。

スクリーンショット 2019-11-01 18.31.14.png

上記スクリーンショットは、ログイン後のHomeページでAccountをクリックした後の画面です。ドロップダウンメニューとログインユーザー用のリンクが表示されます。

スクリーンショット 2019-11-01 18.31.31.png

上記スクリーンショットは、ログイン後のユーザープロフィールページです。Accountのドロップダウンメニューは開いていません。

スクリーンショット 2019-11-01 18.32.55.png

Webブラウザを完全に終了し、再起動した後にHomeページにアクセスすると、右上のリンクがLog inになっています。「アプリケーションのログイン情報が消去され、再びログインを要求されるようになった」という状態ですね。

演習 - レイアウトリンクを変更する

1. ブラウザのcookieインスペクタ機能を使って (8.2.1.1)、セッション用のcookieを削除してみてください。ヘッダー部分にあるリンクは非ログイン状態のものになっているでしょうか? 確認してみましょう。

スクリーンショット 2019-11-01 18.44.52.png

上記スクリーンショットは、ログイン後のHomeページでcookieインスペクタを開いた時点のスクリーンショットです。有効期限がセッションとなっているcookieが存在します。

スクリーンショット 2019-11-01 18.45.10.png

cookieインスペクタでcookie情報を右クリックすると、コンテキストメニューが出ます。

スクリーンショット 2019-11-01 18.45.28.png

コンテキストメニューから「"_sample_app_sesssion-localhost-" を削除」をクリックした後、Homeパージを再読み込みした時点のスクリーンショットです。ヘッダー部分にあるリンクは非ログイン状態のものになっています。

2. もう一度ログインしてみて、ヘッダーのレイアウトが変わったことを確認してみましょう。その後、ブラウザを再起動させ、再び非ログイン状態に戻ったことも確認してみてください。

注意: もしブラウザの [閉じたときの状態に戻す] 機能をオンにしていると、セッション情報も復元される可能性があります。もしその機能をオンにしている場合、忘れずにオフにしておきましょう (コラム 1.1)。

スクリーンショット 2019-11-02 21.30.20.png

上記スクリーンショットは、「一度ログアウトした後に、もう一度ログインした」時点のものです。内容のヘッダー部にAccountメニュー、ほかログイン状態のメニューが表示されています。

スクリーンショット 2019-11-02 21.33.04.png

ログイン後にWebブラウザを完全に終了し、もう一度起動します。内容のヘッダー部にはLog inメニューが表示されています(=非ログイン状態)。

レイアウトの変更をテストする

アプリケーションへのログインに必要な諸技術に触れ、ログイン成功後の動作を実装することができました。続いては、(望ましいとされる手順とは逆ですが)ログイン成功に至るまでの手順に対してテストを実装していきます。

テストが必要な手順

  1. ログイン用のパスを開く
  2. セッション用のパスに有効な情報をpostする
  3. ログイン用リンクが表示されなくなったことを確認する
  4. ログアウト用リンクが表示されていることを確認する
  5. プロフィール用リンクが表示されることを確認する

開発に慣れてくると、図 8.7のモックアップから必要なテストをリストアップできるようになる…とのことです。

fixture

Railsにおいて、「アプリケーションの動作確認のためにDB側に必要となるデータを準備・利用するための仕組み、もしくはそうした仕組みによって準備されたデータの実体」を指します。Userモデルの実装のときにもfixtureは使いましたね。

今回は、「ログイン機能とセッションのテスト」が目的なので、「ログイン可能なユーザーデータ」がDB側に必要となります。当該データが満たすべき条件としては、以下のような感じでしょうか。

  • ユーザーデータは1つあれば十分である
  • 以下の有効な属性が設定されている
    • 名前
    • メールアドレス
    • パスワード

digestメソッド

現サンプルアプリケーションにおいて、ユーザーデータのパスワードは、ハッシュ化された上でDBに保存されています(password_digest属性)。今回のテストで用いるユーザーデータをfixtureで準備するにあたっても、「パスワードのハッシュ化を行うためのメソッドを実装すること」が先に必要です。今回は、digestという名前で当該メソッドを実装することとします。

app/models/user.rb
  class User < ApplicationRecord
    before_save { email.downcase! }
    validates :name,  presence: true, length: { maximum: 50 }
    VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
    validates :email, presence: true, length: { maximum: 255 },
                      format: { with: VALID_EMAIL_REGEX },
                      uniqueness: { case_sensitive: false }
    has_secure_password
    validates :password, presence: true, length: { minimum: 6 }
+
+   # 渡された文字列のハッシュ値を返す
+   def User.digest(unencrypted_password)
+     cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
+     BCrypt::password.create(unencrypted_password, cost: cost)
+   end   
  end

Railsチュートリアル本文においては、以下の記述がポイントとなります。

  • Railsにおいて、パスワードのハッシュ値を生成しているのは、ActiveModel::SecurePasswordモジュール内のhas_secure_passwordメソッドである
  • パスワードのハッシュ値を生成するには、costという値(コストパラメータ)が必要である
  • digestメソッドは、Userモデル内で実装される
    • 今後も多くの場面で活用されるため
  • digestメソッドは、クラスメソッドとして定義される
    • インスタンスごとに個別に必要となる類の計算ではないため

ユーザーログインのテストで使うfixture

digestメソッドの実装まで完了すれば、有効なユーザーを表すfixtureが作成できるようになります。

test/fixtures/users.yml
rhakurei:
  name: Reimu Hakurei
  email: rhakurei@example.com
  password_digest: <%= User.digest('password') %>

fixtureに関するRailsチュートリアル本文の記述では、以下がポイントとなります。

  • fixtureではERbを利用できる
  • fixtureにおいては、生パスワードは文字列で直値として与えるしかない
    • RDBに生パスワードを格納するカラムは存在しないため

ユーザーログインのテストで使うfixture

テストでは以下のようにfixtureのデータを参照することができます。

user = users(:rhakurei)

Railsチュートリアル本文の記述では、以下がポイントとなります。

  • usersというのは、users.ymlのファイル名を指す
    • fixtureのファイル実体はtest/fixtures/以下にあるというのが前提
  • :rhakureiというシンボルは、users.yml内で定義したユーザーを参照するためのキー

有効な情報を使ってユーザーログインをテストする

テストを追加するソースコードは、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 valid information" do
+     get login_path
+     post login_path, params: { session: { email: @user.email, password: 'password' } }
+     assert_redirected_to @user
+     follow_redirect!
+     assert_template 'users/show'
+     assert_select "a[href=?]", login_path, count: 0
+     assert_select "a[href=?]", logout_path
+     assert_select "a[href=?]", user_path(@user)
+   end
  end

typo等していなければ、この時点でテストは通るはずです。

# rails test test/integration/users_login_test.rb
Running via Spring preloader in process 191
Started with run options --seed 23856

  2/2: [===================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.83903s
2 tests, 11 assertions, 0 failures, 0 errors, 0 skips

このテストのポイント

assert_redirected_to @user

上記コードは、リダイレクト先が正しいことをテストしています(その前にpostメソッドが実行されます)。その上で、実際にリダイレクト先に移動します。

follow_redirect!
assert_select "a[href=?]", login_path, count: 0

「レンダリングされたHTMLに、特定の要素が存在しない」という条件のテストを実装することもできます。上述のテストは、「login_pathへのハイパーリンクが存在しない」ことをテストしています。

余談 - 意外にtypoするものである

私は以下のtypoをした結果、テストが通るようになるまでに時間がかかってしまいました。

  • s/password::/Password::/g
  • s/assert_redirect_to/assert_redirected_to/g
  • s/follo_redirect!/follow_redirect!/g
  • s/assert_templete/assert_template/g

皆さんもtypoには気をつけましょう。

演習 - レイアウトの変更をテストする

1. 試しにSessionヘルパーのlogged_in?メソッドから!を削除してみて、リスト 8.23redになることを確認してみましょう。

app/helpers/sessions_helper.rb
  module SessionsHelper

    ...略

    def logged_in?
-     !current_user.nil?
+     current_user.nil?
    end
  end

現状のlogged_in?メソッドは、「ユーザーがログインしていればfalse、その他ならtrueを返す」という状態になっています。

この状態でtest/integration/users_login_test.rbを対象としてテストを実行すると、テストが失敗します。

# rails test test/integration/users_login_test.rb
Running via Spring preloader in process 204
Started with run options --seed 37151

 FAIL["test_login_with_valid_information", UsersLoginTest, 2.189933600002405]
 test_login_with_valid_information#UsersLoginTest (2.19s)
        Expected exactly 0 elements matching "a[href="/login"]", found 1..
        Expected: 0
          Actual: 1
        test/integration/users_login_test.rb:26:in `block in <class:UsersLoginTest>'

  2/2: [===================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.19353s
2 tests, 9 assertions, 1 failures, 0 errors, 0 skips

test_login_with_valid_informationという一連のテストコードでテストが失敗しています。「実際に何に問題があったのか」をさらに詳細に記述しているのは、以下の部分になります。

Expected exactly 0 elements matching "a[href="/login"]", found 1..
Expected: 0
  Actual: 1
test/integration/users_login_test.rb:26:in `block in <class:UsersLoginTest>'

「/login へのハイパーリンクが、0個でなければならないのに1個存在する」という意味のログですね。

なぜテストがこのような失敗の仕方になったのか

ここで改めて_header.html.erbビューの仕様について、関係する部分のみ説明してみます。

  • logged_in?メソッドの戻り値がtrueと評価できる場合、以下のレイアウトリンクのHTMLコードを生成する
    • Profile
    • Settings
    • Log out
  • logged_in?メソッドの戻り値がfalseと評価できる場合、以下のレイアウトリンクのHTMLコードを生成する
    • Log in

また、本テストの動作は以下の通りです。

  • login_pathに対するpostメソッド実行時点で、fixtureにより有効なユーザーデータを与えている
  • follow_redirect!メソッドにより、ユーザーのプロフィールページにリダイレクトされる
  • 生成されたHTMLについて、以下の評価を行う
    • Log inのレイアウトリンクのHTMLコードが存在しない
    • Log outのレイアウトリンクのHTMLコードが存在する
    • ProfileのレイアウトリンクのHTMLコードが存在する

上記を踏まえて、テストが失敗するまでの動作は以下の通りとなります。

  1. ユーザーのログインが成功する
  2. logged_in?メソッドの戻り値がfalseと評価される
  3. ユーザーのプロフィールページに、Log inのレイアウトリンクのHTMLコードが生成される
  4. テストコードと矛盾が生じるので、テストが失敗する

2. 先ほど削除した部分 (!) を元に戻して、テストがgreenに戻ることを確認してみましょう。

app/helpers/sessions_helper.rb
  module SessionsHelper

    ...略

    def logged_in?
-     current_user.nil?
+     !current_user.nil?
    end
  end
# rails test test/integration/users_login_test.rb
Running via Spring preloader in process 217
Started with run options --seed 44877

  2/2: [===================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.04829s
2 tests, 11 assertions, 0 failures, 0 errors, 0 skips

今度こそテストが成功しました。

ユーザー登録時にログイン

現状では、「新規ユーザー登録が完了した時点で、登録したユーザーで直ちにログインする」という実装にはなっていません。これではユーザーがとまどう可能性があります。また、「ユーザー登録が終わってから手動でのログインを促す」というのは、ユーザーに二度手間を強いることになってしまい、ユーザー体験上あまり好ましくありません。

というわけで、「新規ユーザー登録が完了した時点で、登録したユーザーで直ちにログインする」という機能を実装しましょう。

ユーザー登録プロセスに、登録したユーザーでログインする動作を追加する

Usersコントローラのcreateアクションに、本エントリ内で実装を追加したlog_inメソッドを追加すれば、期待する動作を実装できます。

app/controllers/users_controller.rb
  class UsersController < ApplicationController

    ...略

    def create
      @user = User.new(user_params)
      if @user.save
+       log_in @user
        flash[:success] = "Welcome to the Sample App!"
        redirect_to @user
      else
        render 'new'
      end
    end

    ...略
  end

テストの前提となるメソッドを追加する

「ユーザー登録中のログイン動作が正常に行われたか」をテストするために、「テストユーザーがログイン中の場合にtrueを返すメソッド」を定義します。以下のis_logged_in?がそれです。

test/test_helper.rb
  class ActiveSupport::TestCase
    # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
    fixtures :all
    include ApplicationHelper

-   # Add more helper methods to be used by all tests here...
+   # テストユーザーがログイン中の場合にtrueを返す
+   def is_logged_in?
+     !session[:user_id].nil?
+   end
  end

私個人としては、現状ここで「SessionsHelperincludeせずに、あえて新たなメソッドを定義する理由」適切な説明をつけることができません。

いずれにせよ、sessionメソッド1はRails自体で用意された枠組みなので、テストでも利用することができます。そのため、今回は「sessionメソッドによってテストユーザーがログイン中かどうかを判定する」という方法をとっています。

名前をis_logged_in?としたのは、SessionsHelperlogged_in?メソッドとの混同を避けるための配慮です。確かに「開発者に無駄に考えさせない」というのは重要ですね。

ユーザー登録中のログイン動作が正常に行われたかのテストを追加する

ユーザー登録操作に対するテストを記述する場所は、test/integration/users_signup_test.rbでしたね。

test/integration/users_signup_test.rb
  require 'test_helper'

  class UsersSignupTest < ActionDispatch::IntegrationTest

    ...略

    test "valid signup information" do
      get signup_path
      assert_difference 'User.count', 1 do
        post users_path, params: { user: { name: "Example User",
                                          email: "user@example.com",
                                          password: "password",
                                          password_confirmation: "password"} }
      end
      follow_redirect!
      assert_template 'users/show'
      assert_not flash.empty?
+     assert is_logged_in?
    end
  end

上記テストを追加した時点でrails testを実行すると、テストは問題なく通ります。

# rails test
Running via Spring preloader in process 243
Started with run options --seed 4527

  24/24: [=================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.74300s
24 tests, 63 assertions, 0 failures, 0 errors, 0 skips

演習 - ユーザー登録時にログイン

1. リスト 8.25log_inの行をコメントアウトすると、テストスイートはredになるでしょうか? それともgreenになるでしょうか? 確認してみましょう。

app/controllers/users_controller.rb
  class UsersController < ApplicationController
    ...略

    def create
      @user = User.new(user_params)
      if @user.save
-       log_in @user
+       # log_in @user
        flash[:success] = "Welcome to the Sample App!"
        redirect_to @user
      else
        render 'new'
      end
    end

    ...略
  end

この状態でrails testを実行してみます。

# rails test
Running via Spring preloader in process 256
Started with run options --seed 54883

 FAIL["test_valid_signup_information", UsersSignupTest, 2.8520992000012484]
 test_valid_signup_information#UsersSignupTest (2.85s)
        Expected false to be truthy.
        test/integration/users_signup_test.rb:34:in `block in <class:UsersSignupTest>'

  24/24: [=================================] 100% Time: 00:00:03, Time: 00:00:03

Finished in 3.03375s
24 tests, 63 assertions, 1 failures, 0 errors, 0 skips

テストは通りませんでしたredというやつですね。

test/integration/users_signup_test.rbの34行目でExpected false to be truthy.というメッセージを出してテストが失敗した」ということなので、当該部分のソースを見てみます。

test/integration/users_signup_test.rb(34行目)
assert is_logged_in?

is_logged_in?falseを返したためにテストが失敗した」ということになりますね。

2.1. 現在使っているテキストエディタの機能を使って、リスト 8.25をまとめてコメントアウトできないか調べてみましょう。

Mac版のVisual Studio Codeの場合、[Command]+[/]で選択中の行全てに対し、コメントアウト・コメントアウト解除の切り替えを行うことができます。Windows版のVisual Studio Codeの場合、同様の動作をするショートカットキーは[Ctrl]+[/]です。

なお、冪等性のある同等操作としては、以下のショートカットキーが定義されています。

  • 選択中の行全てをコメントアウトする
    • [Command]+[k] [Command]+[c](Mac)
    • [Ctrl]+[k] [Ctrl]+[c](Windows)
  • 選択中の行全てのコメントアウトを解除する
    • [Command]+[k] [Command]+[u](Mac)
    • [Ctrl]+[k] [Ctrl]+[u](Windows)

2.2. また、コメントアウトの前後でテストスイートを実行し、コメントアウトするとredに、コメントアウトを元に戻すとgreenになることを確認してみましょう。

ヒント: コメントアウト後にファイルを保存することを忘れないようにしましょう。また、テキストエディタのコメントアウト機能についてはTest Editor TutorialCommenting Outなどを参照してみてください。

app/controllers/users_controller.rbを、クラス定義を除いて全てコメントアウトしてみます。

app/controllers/users_controller.rb
class UsersController < ApplicationController

  # def show
  #   @user = User.find(params[:id])
  # end

  # def new
  #   @user = User.new
  # end

  # def create
  #   @user = User.new(user_params)
  #   if @user.save
  #     log_in @user
  #     flash[:success] = "Welcome to the Sample App!"
  #     redirect_to @user
  #   else
  #     render 'new'
  #   end
  # end

  # private

  #   def user_params
  #     params.require(:user).permit(:name, :email, :password, :password_confirmation)
  #   end
end

上記のようにruby:app/controllers/users_controller.rbの内容をコメントアウトすると、rails testの実行結果は以下のようになります。

# rails test
Running via Spring preloader in process 269
Started with run options --seed 10904

ERROR["test_layout_links", SiteLayoutTest, 2.2162844999984372]
 test_layout_links#SiteLayoutTest (2.22s)
ActionView::Template::Error:         ActionView::Template::Error: First argument in form cannot contain nil or be empty
            app/views/users/new.html.erb:6:in `_app_views_users_new_html_erb__564052243742235369_47235871591480'
            test/integration/site_layout_test.rb:14:in `block in <class:SiteLayoutTest>'

ERROR["test_invalid_signup_information", UsersSignupTest, 2.3576245000003837]
 test_invalid_signup_information#UsersSignupTest (2.36s)
ActionView::Template::Error:         ActionView::Template::Error: First argument in form cannot contain nil or be empty
            app/views/users/new.html.erb:6:in `_app_views_users_new_html_erb__564052243742235369_47235871591480'
            test/integration/users_signup_test.rb:6:in `block in <class:UsersSignupTest>'

ERROR["test_valid_signup_information", UsersSignupTest, 2.418102399999043]
 test_valid_signup_information#UsersSignupTest (2.42s)
ActionView::Template::Error:         ActionView::Template::Error: First argument in form cannot contain nil or be empty
            app/views/users/new.html.erb:6:in `_app_views_users_new_html_erb__564052243742235369_47235871591480'
            test/integration/users_signup_test.rb:24:in `block in <class:UsersSignupTest>'

ERROR["test_login_with_valid_information", UsersLoginTest, 2.8301763999988907]
 test_login_with_valid_information#UsersLoginTest (2.83s)
ActionView::Template::Error:         ActionView::Template::Error: undefined method `name' for nil:NilClass
            app/views/users/show.html.erb:1:in `_app_views_users_show_html_erb___3601025620786985656_47235866694620'
            test/integration/users_login_test.rb:24:in `block in <class:UsersLoginTest>'

ERROR["test_should_get_new", UsersControllerTest, 2.9167043999987072]
 test_should_get_new#UsersControllerTest (2.92s)
ActionView::Template::Error:         ActionView::Template::Error: First argument in form cannot contain nil or be empty
            app/views/users/new.html.erb:6:in `_app_views_users_new_html_erb__564052243742235369_47235871591480'
            test/controllers/users_controller_test.rb:5:in `block in <class:UsersControllerTest>'

  24/24: [=================================] 100% Time: 00:00:03, Time: 00:00:03

Finished in 3.20887s
24 tests, 45 assertions, 0 failures, 5 errors, 0 skips

続いて、app/controllers/users_controller.rbのコメントアウトを解除します。

app/controllers/users_controller.rb
class UsersController < ApplicationController

  def show
    @user = User.find(params[:id])
  end

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

  private

    def user_params
      params.require(:user).permit(:name, :email, :password, :password_confirmation)
    end
end

このとき、rails testの実行結果は以下のようになります。

# rails test
Running via Spring preloader in process 282
Started with run options --seed 16181

  24/24: [=================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.76136s
24 tests, 63 assertions, 0 failures, 0 errors, 0 skips

  1. @zettaittenani氏のQiita記事であるRails の session を完全に理解したにあるように、継承関係をたどってsessionメソッドそのものの定義に至るまでの道のりはかなり複雑です。sessionメソッドそのものの定義について理解するためには、Railsチュートリアルの範疇を超えるほどのRubyの知識が求められます。Rubyそのものについてそこまで突っ込んで勉強するのは、Railsチュートリアルを一度完走してからでも遅くないように思います。 

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