何を実装するか
今までに、「ログインフォームで無効な値が送信された際の処理」については実装できました。次に実装するのは以下の動作です。
- 有効なメールアドレスとパスワードの組でログインできるようにする
- cookiesを使った一時セッションでログイン状態を保持できるようにする
- 有効期限はブラウザウィンドウが閉じられるまでである
- 実際にログイン中のユーザーのユーザー情報を活用する
Sessionヘルパーモジュール
- 実体は
app/helpers/sessions_helper.rb
である -
rails generate controller sessions
により自動生成される - 全てのビューに自動的に読み込まれる
- 全てのコントローラで同モジュールを使えるようにするには、
app/controllers/application_controller.rb
にinclude
すればOK- セッションに関する機能は多くのコントローラで使うので、この処理の有用性は高い
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
+ include SessionsHelper
end
log_in
メソッド
session
メソッド
Railsのメソッドとしてのsession
1は、以下の機能を持ったメソッドです。
- ユーザーのWebブラウザ内の一時Cookieに情報を格納する
- ユーザーのWebブラウザ内の一時Cookieから情報を取り出す
sessions
メソッドはハッシュのように使うことができます。例えば、「ユーザーのWebブラウザ内に暗号化済みのユーザーIDを格納する」という処理を行う場合、以下のように記述します。すると、以後session[:user_id]
でユーザーIDを元通りに取り出すことができるようになるのです。
session[:user_id] = user.id
Sessionsコントローラとは、名前が似ていますが無関係です。
log_in
メソッドを実装する
場所は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
メソッドを実装したので、この時点で「ログイン成功時の動作」を定義することができるようになりました。実際の動作は以下のようになります。
- ユーザーログイン動作を行い、セッションの
create
アクションを完了する - ユーザーのプロフィールページにリダイレクトする
実際のコードは以下のようになります。場所は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の情報を調べることができます。

「値」は以下のようになっています。
_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におけるテーブル設計から、ユーザーIDが
- RDBへのアクセスは必要最小限に留めるべきである
- メモリ内のリソースへのアクセスに比べてオーバーヘッドが大きい
メモ化 - User.find_by
の実行結果をインスタンス変数に格納する
User.find_by
の実行結果は、@current_user
というインスタンス変数に格納することとします。こうした手法はメモ化(Wikipediaにおけるメモ化の解説)と呼ばれています。今回のサンプルアプリケーションにおいては、「1リクエスト内におけるRDBへの問い合わせを1度限りにする」という意義があります。
メソッド全体の動作としては以下のようになります。
-
@current_user
がnil
であれば、User.find_by
の実行結果を@current_user
というインスタンス変数に格納する -
@current_user
がnil
でなければ、@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
となるのはfalse
とnil
のみである。それ以外の全てのオブジェクトは、真偽値として評価するとtrue
になる」というのがポイントですね。User.find_by
の戻り値は、(戻り値がnil
でなければ)真偽値として評価した場合、常にtrue
となります。
結果、「@current_user
がnil
の場合のみ、User.find_by
が実行される」という動作が実現されることになります。
上述コードは、自己代入演算子||=
を用いてさらに簡略化することができます。
@current_user ||= User.find_by(id: session[:user_id])
自己代入演算子||=
については、Ruby独特の代入演算子 ||= の意味について説明してみたにてより突っ込んだ解説を書いてみました(それ以上に突っ込んだ仕様については、同記事における @scivola さんのコメントも参考になります)。
一時セッションからユーザー情報を取り出すコードの実装
以上を踏まえた上で、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に記したステップに従って、||=演算子がうまく動くことも確認してみましょう。
>> 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_user
がnil
でない」ことと同義になります。このようなチェックは、否定演算子!
を使って行います。
実装場所は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のフォームからPUT
やDELETE
といったリクエストを送出することはできません。link_to
の:method
オプションの値に:delete
を設定してから、実際にDELETE
リクエストが送出されるに至るまでには、裏でRailsの仕組みが色々と動いているのです。この辺を丹念に解説していくと、それだけでQiitaの記事が1本書けるほどのボリュームになります。
プロフィール用のリンク
<%= link_to "Profile", current_user %>
このコードは、以下のように書くこともできます。
<%= link_to "Profile", user_path(current_user) %>
Railsにより、current_user
はuser_path(current_user)
に自動的に変換されます。
Railsチュートリアルがcurrent_user
という書き方を採用したのは、「コード全体としてバランスを良くするために、同じような書き方をしているコードについて括弧の数を揃える」といった理由であると推測します。
ログイン用のリンク
ユーザーがログインしていない場合のページには、config/routes.rb
で定義したlogin_path
を使用して、ログインフォームへのリンクを作成します。
<%= link_to "Log in", login_path %>
レイアウトリンクをページ内容ヘッダー部に適用する
編集対象のファイルは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のドロップダウンメニュー機能を有効にする
ここまでで、dropdown
やdropdown-menu
といったクラスが使われていることに気がついたかもしれません。これらは、Bootstrapのドロップダウンメニュー機能で使われるクラス名です。
しかしながら、現段階ではBootstrapのドロップダウンメニュー機能を使うことはできません。必要なJavaScriptライブラリが足りないためです。実際に追加で必要となるのは以下のライブラリです。
- Bootstrapに同梱されているJavaScriptライブラリ
- JQuery
いずれもJavaScriptライブラリですね。これらのライブラリを読み込むようにするためには、アセットパイプラインで読み込みを指示する必要があります。コードを追加する箇所は、app/assets/javascripts/application.js
となります。
//= require rails-ujs
+ //= require jquery
+ //= require bootstrap
//= require turbolinks
//= require_tree .
ログインできるようになった!
ここまでのコードの追加で、以下の機能の実装が完了しました。
- ログインパスにアクセスする
- 有効なユーザーとしてログインする
結果、アプリケーションの動作はどのように変化したでしょうか。いくつかスクリーンショットを掲載します。

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

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

Webブラウザを完全に終了し、再起動した後にHomeページにアクセスすると、右上のリンクがLog inになっています。「アプリケーションのログイン情報が消去され、再びログインを要求されるようになった」という状態ですね。
演習 - レイアウトリンクを変更する
1. ブラウザのcookieインスペクタ機能を使って (8.2.1.1)、セッション用のcookieを削除してみてください。ヘッダー部分にあるリンクは非ログイン状態のものになっているでしょうか? 確認してみましょう。

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

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

コンテキストメニューから「"_sample_app_sesssion-localhost-" を削除」をクリックした後、Homeパージを再読み込みした時点のスクリーンショットです。ヘッダー部分にあるリンクは非ログイン状態のものになっています。
2. もう一度ログインしてみて、ヘッダーのレイアウトが変わったことを確認してみましょう。その後、ブラウザを再起動させ、再び非ログイン状態に戻ったことも確認してみてください。
注意: もしブラウザの [閉じたときの状態に戻す] 機能をオンにしていると、セッション情報も復元される可能性があります。もしその機能をオンにしている場合、忘れずにオフにしておきましょう (コラム 1.1)。

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

ログイン後にWebブラウザを完全に終了し、もう一度起動します。内容のヘッダー部にはLog inメニューが表示されています(=非ログイン状態)。
レイアウトの変更をテストする
アプリケーションへのログインに必要な諸技術に触れ、ログイン成功後の動作を実装することができました。続いては、(望ましいとされる手順とは逆ですが)ログイン成功に至るまでの手順に対してテストを実装していきます。
テストが必要な手順
- ログイン用のパスを開く
- セッション用のパスに有効な情報をpostする
- ログイン用リンクが表示されなくなったことを確認する
- ログアウト用リンクが表示されていることを確認する
- プロフィール用リンクが表示されることを確認する
開発に慣れてくると、図 8.7のモックアップから必要なテストをリストアップできるようになる…とのことです。
fixture
Railsにおいて、「アプリケーションの動作確認のためにDB側に必要となるデータを準備・利用するための仕組み、もしくはそうした仕組みによって準備されたデータの実体」を指します。Userモデルの実装のときにもfixtureは使いましたね。
今回は、「ログイン機能とセッションのテスト」が目的なので、「ログイン可能なユーザーデータ」がDB側に必要となります。当該データが満たすべき条件としては、以下のような感じでしょうか。
- ユーザーデータは1つあれば十分である
- 以下の有効な属性が設定されている
- 名前
- メールアドレス
- パスワード
digest
メソッド
現サンプルアプリケーションにおいて、ユーザーデータのパスワードは、ハッシュ化された上でDBに保存されています(password_digest
属性)。今回のテストで用いるユーザーデータをfixtureで準備するにあたっても、「パスワードのハッシュ化を行うためのメソッドを実装すること」が先に必要です。今回は、digest
という名前で当該メソッドを実装することとします。
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
という値(コストパラメータ)が必要である- Ruby on Rails の has_secure_password のコードを読んでみる - What is it, naokirin?によれば、「BCryptにおける、暗号化の計算回数を指す値」とのことである
- Rails側ででは、「テスト環境ではコストパラメータを最小にし、本番環境では十分な値を確保する」というメカニズムが実装されている
-
digest
メソッドは、Userモデル内で実装される- 今後も多くの場面で活用されるため
-
digest
メソッドは、クラスメソッドとして定義される- インスタンスごとに個別に必要となる類の計算ではないため
ユーザーログインのテストで使うfixture
digest
メソッドの実装まで完了すれば、有効なユーザーを表すfixtureが作成できるようになります。
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/
以下にあるというのが前提
- fixtureのファイル実体は
-
:rhakurei
というシンボルは、users.yml
内で定義したユーザーを参照するためのキー
有効な情報を使ってユーザーログインをテストする
テストを追加するソースコードは、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.23がred
になることを確認してみましょう。
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コードが存在する
上記を踏まえて、テストが失敗するまでの動作は以下の通りとなります。
- ユーザーのログインが成功する
-
logged_in?
メソッドの戻り値がfalse
と評価される - ユーザーのプロフィールページに、Log inのレイアウトリンクのHTMLコードが生成される
- テストコードと矛盾が生じるので、テストが失敗する
2. 先ほど削除した部分 (!
) を元に戻して、テストがgreen
に戻ることを確認してみましょう。
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
メソッドを追加すれば、期待する動作を実装できます。
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?
がそれです。
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
私個人としては、現状ここで「SessionsHelper
をinclude
せずに、あえて新たなメソッドを定義する理由」適切な説明をつけることができません。
いずれにせよ、session
メソッド1はRails自体で用意された枠組みなので、テストでも利用することができます。そのため、今回は「session
メソッドによってテストユーザーがログイン中かどうかを判定する」という方法をとっています。
名前をis_logged_in?
としたのは、SessionsHelper
のlogged_in?
メソッドとの混同を避けるための配慮です。確かに「開発者に無駄に考えさせない」というのは重要ですね。
ユーザー登録中のログイン動作が正常に行われたかのテストを追加する
ユーザー登録操作に対するテストを記述する場所は、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.25のlog_in
の行をコメントアウトすると、テストスイートはred
になるでしょうか? それともgreen
になるでしょうか? 確認してみましょう。
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.
というメッセージを出してテストが失敗した」ということなので、当該部分のソースを見てみます。
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 TutorialのCommenting Outなどを参照してみてください。
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
のコメントアウトを解除します。
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
-
@zettaittenani氏のQiita記事であるRails の session を完全に理解したにあるように、継承関係をたどって
session
メソッドそのものの定義に至るまでの道のりはかなり複雑です。session
メソッドそのものの定義について理解するためには、Railsチュートリアルの範疇を超えるほどのRubyの知識が求められます。Rubyそのものについてそこまで突っ込んで勉強するのは、Railsチュートリアルを一度完走してからでも遅くないように思います。 ↩ ↩2