紙一重の積み重ね

35歳のエンジニアがなれる最高の自分を目指して、学んだことをこつこつ情報発信するブログです。

【8章】Ruby on Railsチュートリアル演習まとめ&解答例【8.2 ログイン】

はじめに

Ruby on Rails チュートリアル実例を使ってRailsを学ぼう 第4版の 8章 8.2 ログインの演習まとめ&解答例です。

個人の解答例なので、誤りがあればご指摘ください。

8.2.1 log_inメソッド

本章での学び

Railsのセッション用ヘルパー

Sessionsコントローラを作成すると自動生成される。 また、ビューでも自動でインクルードされる。

Railsの全コントローラのベースクラス(Application_controller.rb)にインクルードすれば、すべてのコントローラでセッション用ヘルパーが使えるようになる。

  include SessionsHelper

sessionメソッド

Railsで事前定義済みのメソッド。ハッシュのように扱える。

session[:user_id] = user.id

ユーザのブラウザ内の一時cookiesに暗号化済みのユーザIDが自動で生成される。後続のページで、session[:user_id]を使うことで、ユーザIDを元通りに取り出せる。

cookiesメソッドとは対照的に、sessionメソッドはブラウザを閉じた瞬間に有効期限が終了する。

セッション用ヘルパーの修正

同じログイン手法を様々な場所で使いまわせるようにするために、セッション用ヘルパーにlog_inメソッドを追加する。

  def log_in(user)
    session[:user_id] = user.id
  end

sessionメソッドで暗号化した一時cookiesは、攻撃者が盗んだとしても、それを使って本物のユーザとしてログインすることはできない。(永続的な情報ではないためと思われる)

sessionsコントローラの修正

createアクションを修正する。 ユーザログインを行い、ユーザのプロフィールページへリダイレクトを行う。

    if user && user.authenticate(params[:session][:password])
      log_in user
      redirect_to user

redirect_to userは、Railsgがuser_url(user)に自動的に変換する。

演習1

有効なユーザーで実際にログインし、ブラウザからcookiesの情報を調べてみてください。このとき、sessionの値はどうなっているでしょうか? ヒント: ブラウザでcookiesを調べる方法が分からない? 今こそググってみるときです! (コラム 1.1)

ログインページヘアクセスする。 chromeでF12を押して開発ツールを表示して、sessionの値を確認。

image

有効なユーザでログインする。

image

演習2

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

8.2.2 現在のユーザー

本章での学び

current_userメソッド

セッションIDに対応する情報をデータベースから取得することができるようにするcurrent_userメソッドを作成する。

例)セッションIDに対応するユーザ名を取得する。

<%= current_user.name %>

find_byメソッドを使うことで、nilが返ってくることを防ぐ。

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

Railsの処理高速化のテクニック

User.find_byの実行結果を、インスタンス変数に保存することで、データベースの読み込みは1回になる。 以降はインスタンス変数を返すようになる。

if @current_user.nil?

or equals代入演算子

||=という代入演算子を使うことで、or演算子を短縮して書くことができる。 javaで言うところの、x = x + 1を、x += 1と書くのと同じイメージ。

短縮系 短縮しない場合
x += 1 x = x +1
@foo | = “bar” |@foo = @foo || “bar”

「変数の値がnilなら変数に代入するが、nilでなければ代入しない」という操作が、Rubyではよく行われる。

Rubyの王道

書ける処理は短縮形で書く。 or equals演算子を使うことで、以下の複数行の処理が1行で書ける。

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

上のコードと同じ意味になる。 こちらがRubyの王道。

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

演習1

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

ユーザIDに存在しない100を指定して検索。 nilが返ってくることを確認。

>> User.find_by(id: '100')
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 100], ["LIMIT", 1]]
=> nil

演習2

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

ユーザIDが1のユーザ情報をもとに、sessionハッシュを生成する。

?> user = User.find_by(id: '1')
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2017-05-07 21:50:35", updated_at: "2017-05-07 21:50:35", password_digest: "$2a$10$esrzyVS.n7KGJ27bcs7UF.78tD7e75pMkwy6gnnwhlQ...">
>> session = { user_id: user.name, name: user.name, email: user.email }
=> {:user_id=>"Rails Tutorial", :name=>"Rails Tutorial", :email=>"example@railstutorial.org"}

リストの手順に従い、||=演算子を検証する。 ユーザーIDが1(というか最初のユーザ)でハッシュを生成する場合は、 session[:user_id] = User.first.idでハッシュを生成したほうが楽。

?> session = {}
=> {}
>> session[:user_id] = nil
=> 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
>> session[:user_id] = User.first.id
  User Load (0.2ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> 1
>> @current_user ||= User.find_by(id: session[:user_id])
  User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2017-05-07 21:50:35", updated_at: "2017-05-07 21:50:35", password_digest: "$2a$10$esrzyVS.n7KGJ27bcs7UF.78tD7e75pMkwy6gnnwhlQ...">
>> @current_user ||= User.find_by(id: session[:user_id])
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2017-05-07 21:50:35", updated_at: "2017-05-07 21:50:35", password_digest: "$2a$10$esrzyVS.n7KGJ27bcs7UF.78tD7e75pMkwy6gnnwhlQ...">

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

本章での学び

ログアウト用リンク

ログアウト用のリンクは以下の通り、引数にmethod: : deleteを渡している。 Webブラウザは実際にはDELETEリクエストを発行できないため、RailsではJavaScriptを使って偽装している。

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

ユーザプロフィール用リンク

省略形でcurrent_userを使う。

<%= link_to "Profile", current_user %>

Railsによって、以下のコードに自動変換される。

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

ドロップダウンメニューの作成

bootstrapのdropdownクラスや、dropdown-menuにて実現する。 これらのクラスを実装しただけでは、ドロップダウンメニューは表示されない。

image

bootstrapを有効にする

ドロップダウンメニューを有効にするために、application.jsにBootstrapのカスタムjsをインクルードするように指定する必要がある。

//= require bootstrap

railsを再起動すると、ドロップダウンメニューが表示される。 image.png

演習1

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

chromeの開発ツールでcookie情報を表示する。 image.png

右クリック=>clearでcookieを削除して、F5で更新。 image.png

ヘッダー部にあるリンクが非ログイン状態になることを確認。 image.png

演習2

もう一度ログインしてみて、ヘッダーのレイアウトが変わったことを確認してみましょう。その後、ブラウザを再起動させ、再び非ログイン状態に戻ったことも確認してみてください。注意: もしブラウザの [閉じたときの状態に戻す] 機能をオンにしていると、セッション情報も復元される可能性があります。もしその機能をオンにしている場合、忘れずにオフにしておきましょう (コラム 1.1)。

再度ログイン。ヘッダーのレイアウトが変わることを確認。

image.png

ブラウザをxで閉じてから再起動。ヘッダーのレイアウトがログイン前に戻ることを確認。

image.png

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

本章での学び

テスト用データを生成する

fixtureファイルで作成することができる。 テストに必要なデータをtestデータベースに読み込んでおくことが可能。

fixture向けのdigestメソッドを定義する

fixtureで有効なメールアドレス、名前を設定しておく。 テスト中にそのユーザとしてログインするために、そのユーザの有効なパスワードも設定しておく。 password_digest属性をユーザのfixtureに追加するために、Userモデル内に、独自のdigestメソッドを追加する。

bcryptによるパスワードのハッシュ化

has_secure_passwordでbcryptのパスワードが生成されるため、同様の仕組みにする。

BCrypt::Password.create(string, cost: cost)

stringは、ハッシュ化する文字列。 costは、コストパラメータ。ハッシュを算出するための計算コストを指定する。コストパラメータの値を高くすることで、ハッシュからオリジナルのパスワードを計算で算出することは困難になる。

三項演算子によるコストパラメータの計算

テストにおいてはコストパラメータを高くする意味はない。 三項演算子で記載されたhas_secure_password内の実装を参考にする。

参考演算子とは、

  if boolean?
    何かをする
  else
    別のことをする
  end

上記の分岐を、1行で記載することができる。

  論理値? ? 何かをする : 別のことをする

上記を踏まえ、has_secure_password内の実装を見ると、ActiveModel::SecurePassword.min_costがtrueなら、BCrypt::Engine::MIN_COSTを使用する。つまり、テスト中はコストを最小にして、本番ではコストを明確に指定して計算する。

cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                              BCrypt::Engine.cost
クラスメソッドとインスタンスメソッド

クラスメソッドは、クラスオブジェクトから実行可能なメソッド (Javaで言うところのstaticメソッド)

def self.method_nameで定義する。 今回のdigestメソッドも、クラスメソッドで定義している。 (ユーザーごとのインスタンスで計算する必要はないため、インスタンスメソッドではなくクラスメソッドにしている)

インスタンスメソッドは、インスタンスオブジェクトから実行可能なメソッド

違いは以下を参照。 http://qiita.com/tbpgr/items/56eb65c0ea5882abbb07

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

名前、メールアドレス、パスワードを定義する。 fixtureの中では、ERbを使用することができる。

michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

fixtureの生パスワード

fixture内でpassword属性を指定することはできない(DBのカラムにもpasswordがいない)ため、テストケース内で直接記載する。

fixtureのユーザが増えても、全員、同じパスワードの値(今回はpassword)を使用する。

テストシナリオのコード化

0.事前準備 usersとは、fixtureのファイル名users.ymlのこと。 users.ymlから、michaelユーザの情報を読み込み、インスタンス変数へ格納する。

def setup
  @user = users(:michael)
end

1.ログイン用のパスを開く get login_path

2.セッション用パスに有効な情報をpostする assert_redirected_to @userで、リダイレクト先が正しいかを確認する。 follow_redirect!で、実際にリダイレクトする。

post login_path, params: { session: { email: @user.email,
                                      password: 'password' } }
assert_redirected_to @user
follow_redirect!
assert_template 'users/show'

3.ログイン用リンクが表示されなくなったことを確認する count: 0を付与することで、assert_selectに渡したパターンに一致するリンクが0可動化を確認する。 assert_select "a[href=?]", login_path, count: 0

4.ログアウト用リンクが表示されていることを確認する assert_select "a[href=?]", logout_path

5.プロフィール用リンクが表示されていることを確認する assert_select "a[href=?], user_path(@user)"

テストファイル内の特定のテストのみ実行する

テストファイル内に複数のテストシナリオが存在する場合、テストファイル名とテスト名を指定することができる。

--nameで指定するテスト名は、接頭語に「test_」をつけて、test ‘…’ doの部分の単語をアンダースコアでつないだ文字列で指定する。

  test "login with valid information" do
    ・・・略
  end
yokoyan:~/workspace/sample_app (basic-login) $ rails test test/integration/users_login_test.rb --name test_login_with_valid_information
Running via Spring preloader in process 3098
Started with run options --name test_login_with_valid_information --seed 3699

  1/1: [=====================================================================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.82456s
1 tests, 6 assertions, 0 failures, 0 errors, 0 skips

演習1

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

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

テスト結果がredになることを確認。 ユーザーがログインしていても、trueを返せないためredになっている。

yokoyan:~/workspace/sample_app (basic-login) $ rails test test/integration/users_login_test.rb --name test_login_with_valid_information
Running via Spring preloader in process 3164
Started with run options --name test_login_with_valid_information --seed 35997

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

  1/1: [=====================================================================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.88460s
1 tests, 4 assertions, 1 failures, 0 errors, 0 skips

演習2

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

修正箇所をもとに戻す。

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

テスト結果がgreenになることを確認。

yokoyan:~/workspace/sample_app (basic-login) $ rails test test/integration/users_login_test.rb --name test_login_with_valid_information
Running via Spring preloader in process 3246
Started with run options --name test_login_with_valid_information --seed 37023

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

Finished in 1.02458s
1 tests, 6 assertions, 0 failures, 0 errors, 0 skips

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

本章での学び

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

Usersコントローラの、createメソッド内で、log_in @userを追加すればOK。 Sessionsコントローラがあることで、UsersコントローラでもSessionsHelperで定義しているlog_inメソッドが使える。

正確には、application_controller.rb内で、SessionsHelperをインクルードしているため、Usersコントローラでもlog_inメソッドが使える。

  def create
    @user = User.new(user_params)
    if @user.save
      log_in(@user)
      ・・・略
  end

テストヘルパーメソッドの修正

ログイン中かどうかを確認するテストを追加する。 ログイン中かどうかの判定は、sessions_helper.rbで定義したヘルパーメソッド、logged_in?で取得できる。

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

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

しかし、ヘルパーメソッドはテスト内から呼び出すことができない。 そのため、test_helper.rb内に新しく、is_logged_in?を定義する。 sessionヘルパー内のcurrent_userを呼び出せないため、代わりにsessionメソッドを使って実現する。

  # テストユーザーがログイン中の場合にtrueを返す
  def is_logged_in?
    !session[:user_id].nil?
  end

ユーザー登録後のログインのテスト

1.ユーザー登録画面のパスを開く

 get signup_path

2.ユーザー登録情報をpostして、ユーザー登録数が1増えているかを確認する

  assert_difference 'User.count', 1 do
    post users_path , params: { { name: "Example User",
                                  email: "user@example.com",
                                  password: "password",
                                  password_confirmation: "password" }}
  end

3.リダイレクトする

  follow_redirect!

4.ユーザー情報表示画面のテンプレートが表示されることを確認する

  assert_template 'users/show'

5.フラッシュメッセージ(エラーメッセージ)が無いことを確認する

  assert_not flash.empty?

6.ログイン中かどうかを確認する 定義したis_logged_in?ヘルパーメソッドを呼び出す。

  assert is_logged_in?

演習1

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

log_inをコメントアウトしてテストを実行。

    if @user.save
      #log_in(@user)

redになることを確認。 原因はユーザ登録後、ログインしていないため。 assert is_logged_in?でエラーになっている。

yokoyan:~/workspace/sample_app (basic-login) $ rails test
Running via Spring preloader in process 2623
Started with run options --seed 59224

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

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

Finished in 1.47616s
23 tests, 63 assertions, 1 failures, 0 errors, 0 skips

コメントアウトを戻すとgreenになることを確認。

演習2

現在使用しているテキストエディタの機能を使って、リスト 8.25をまとめてコメントアウトできないか調べてみましょう。また、コメントアウトの前後でテストスイートを実行し、コメントアウトすると red に、コメントアウトを元に戻すと green になることを確認してみましょう。ヒント: コメントアウト後にファイルを保存することを忘れないようにしましょう。また、テキストエディタのコメントアウト機能については Test Editor Tutorial の Commenting Out (英語) などを参照してみてください。

cloud9の場合、Ctrl+/で複数行コメントアウトできる。 もちろんテストはredになる。

yokoyan:~/workspace/sample_app (basic-login) $ rails test
Running via Spring preloader in process 2723
Started with run options --seed 55337

ERROR["test_layout_links", SiteLayoutTest, 0.8837061480007833]
 test_layout_links#SiteLayoutTest (0.88s)
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__3664408681092639721_27399160'
            test/integration/site_layout_test.rb:17:in `block in <class:SiteLayoutTest>'

ERROR["test_invalid_signup_information", UsersSignupTest, 0.9015909680019831]
 test_invalid_signup_information#UsersSignupTest (0.90s)
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__3664408681092639721_26560060'
            test/integration/users_signup_test.rb:9:in `block in <class:UsersSignupTest>'

ERROR["test_valid_signup_information", UsersSignupTest, 0.916260575002525]
 test_valid_signup_information#UsersSignupTest (0.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__3664408681092639721_25494440'
            test/integration/users_signup_test.rb:29:in `block in <class:UsersSignupTest>'

ERROR["test_login_with_valid_information", UsersLoginTest, 1.090877412003465]
 test_login_with_valid_information#UsersLoginTest (1.09s)
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___1927824141870267168_46476740'
            test/integration/users_login_test.rb:27:in `block in <class:UsersLoginTest>'

ERROR["test_should_get_new", UsersControllerTest, 1.1993190759967547]
 test_should_get_new#UsersControllerTest (1.20s)
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__3664408681092639721_38933240'
            test/controllers/users_controller_test.rb:5:in `block in <class:UsersControllerTest>'

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

Finished in 1.20674s
23 tests, 43 assertions, 0 failures, 5 errors, 0 skips

再度コメントアウトを戻して、テストがgreenになることを確認。

yokoyan:~/workspace/sample_app (basic-login) $ rails test
Running via Spring preloader in process 2801
Started with run options --seed 61736

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

Finished in 1.26744s
23 tests, 63 assertions, 0 failures, 0 errors, 0 skips

おわりに

ユーザ登録、ログイン機能がひと通り完成しました。 RailsはSQLを意識しなくても、データベースのCRUD処理ができてしまうのがすごいなあと感じます。 その分、中で何が起こっているのかを理解するために、もう一歩踏み込みながら学習したいと思います。