Help us understand the problem. What is going on with this article?

Rails Tutorialの知識から【ポートフォリオ】を作って勉強する話 #8 ログイン/ログアウト, FactroyBot編

こんな人におすすめ

  • プログラミング初心者でポートフォリオの作り方が分からない
  • Rails Tutorialをやってみたが理解することが難しい

前回:#7 ログイン機能の下準備編
次回:#9 永続セッション, cookie編

今回の流れ

  1. 今回のゴールを把握する
  2. ログイン/ログアウトに必要なメソッドを作る
  3. Sessionsコントローラーを作る
  4. ヘッダーのボタンを動的にする
  5. テストを作る

※ この記事は、ポートフォリオを作る理由をweb系自社開発企業に転職するためとします。
※ 2020年4月3日、記事を大幅に更新しました。

今回のゴールを把握する

ゴールは、以下の3つです。

  • ログイン/ログアウト機能を実装する
  • ユーザー登録時に、ログイン済みにする
  • ログイン中は'Logout'、ログアウト中は'Login'ボタンを表示する

まずは、ログイン/ログアウトに必要なメソッドをSessionsヘルパーに4つ作ります。

続いて、ログイン/ログアウトを関係する、Sessionsコントローラーを作ります。
その際、ユーザー登録時にログイン済みにすることも組み込みます。

続いて、ヘッダーにあるログイン/ログアウト時のボタン表示を切り替えます。
その際、Bootstrapでレスポンシブにデザインします。

最後に、ログイン/ログアウト機能のテストを作ります。
その際、テスト用データの作成にFactroyBotを使います。

以上です。
(ログイン画面のビューは#7で完成しているため、省略します。)

ログイン/ログアウトに必要なメソッドを作る

ログイン/ログアウトに必要なメソッドをSessionsヘルパーに作ります。
作るメソッドは以下の4つです。

  • ログインを表現する、log_inメソッド
  • 現在のユーザーを特定する、current_userメソッド
  • ログインしているかどうかを確認する、logged_in?メソッド
  • ログアウトを表現する、log_outメソッド

1つ目:log_inメソッドを作る

ログインを表現する、log_inメソッドを作ります。
ログインはsessionメソッドを使うことで表現できます。
引数に与えられるUserモデルのidを、sessionに代入するだけです。

app/helpers/sessions_helper.rb
module SessionsHelper

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

sessionメソッドについて、混同しそうな箇所を解説します。
このメソッドは、以前作成したSessionsコントローラやビューと異なります。
以下のような違いがあります。

  • sessionメソッド → Railsに用意された、ログインに便利なメソッド
  • Sessionコントローラ/ビュー → ログイン関係のコントローラ/ビューを表現するために、勝手に命名したもの

参考になりました↓
【Rails入門】sessionの使い方まとめ

2つ目:current_userメソッドを作る

現在のユーザーを特定する、current_userメソッドを作ります。
これにより、今後別ページでもユーザーを取り出すことができます。

app/helpers/sessions_helper.rb
  # 中略
  def current_user
    if session[:user_id]
      @current_user ||= User.find_by(id: session[:user_id])
    end
  end
  # 中略

以下の2つについて解説します。

  • findとfind_byの違いについて
  • ||= について

findとfind_byの違いについて知る

find_byは#7でも使いましたが、解説をしていませんでした。
よって、ここで取り上げます。
両者には、以下のような違いがあります。

find

  • 見つからないとエラーになる
  • id以外で探せない

find_by

  • 見つからないとnilになる
  • id以外でも探せる

current_userメソッドにおいて大事なのは、エラーになるかどうかです。
ログアウトは、sessionを削除することで表現します。
その際、sessionの中身が存在しなくなります。

つまり、ログアウト状態でfindメソッドを使ってしまうとエラーが発生します。
よって、find_byメソッドを使います。

参考になりました↓
find、find_by、whereの違い
find(42)とfind_by(id: 42)の違いについて

||= について知る

||= について解説します。
以下の2つのコードは等価です。

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

なじみ深い代入演算子と比べると、理解しやすいかと思います。

x += 1 → x = x + 1
A ||= 1 → A = A || 1

続いて、式の判定について解説します。
判定について、以下の特徴があげられます。

  • ||は、trueの場合、処理が終了する
  • ||は、左から順に判定する
@current_user || User.find_by(id: session[:user_id])

よって、上記の判定文はこのような意味になります。

current_userが存在する → そのままcurrent_userを代入する
current_userが存在しない → sessionによって探したUserモデルをcurrent_userに代入する

参考になりました↓
【Rails】@current_user ||= User.find_by(id: session[:user_id])という書き方について

3つ目:logged_in?メソッドを作る

ログインしているかどうかを確認する、logged_in?メソッドを作ります。
具体的には、true/falseで確認できるようにします。
これにより、ログイン/ログアウト時のレイアウトが変更できます。

app/helpers/sessions_helper.rb
# 中略
  def logged_in?
    !current_user.nil?
  end
end

current_userの前に「!」がついています。
「!」をつけることで、真偽値を反転することができます。

つけないと、current_userがいない時(=ログアウト時)にtrueとなります。
人間の持つ感覚として不適なので、反転させます。

4つ目:log_outメソッドを作る

ログアウトを表現する、log_outメソッドを作ります。
ログアウトは、sessionを削除することで表現します。

/app/helpers/sessions_helper.rb
# 中略
def log_out
  session.delete(:user_id)
  @current_user = nil
end

以上で、ログイン/ログアウトに必要なメソッドを4つ完成しました。

Sessionsコントローラーを作る

ログイン/ログアウトを関係する、Sessionsコントローラーを作ります。
#7で作ったコントローラーの拡張になります。

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      redirect_to user
    else
      flash.now[:danger] = 'メールアドレスかパスワードが正しくありません'
      render 'new'
    end
  end

  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end

ユーザー登録時にログイン済みにすることも組み込みます。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  # 中略
  def create
    @user = User.new(user_params)
    if @user.save
      log_in @user
      flash[:success] = "アカウントの作成が成功しました"
      redirect_to @user
    else
      render 'new'
    end
  end
  # 中略
end

ヘッダーのボタンを動的にする

ヘッダーにあるログイン/ログアウト時のボタン表示を切り替えます。
動的なボタンは、ERb内に簡単なif文を設けることで実現します。
Bootstrapでレスポンシブにデザインします。
lantern_lantern_responsible_1.gif

app/views/layouts/_header.html.erb
<header>
  <div class="container">
    <nav class="navbar navbar-expand-md navbar-dark fixed-top navbar-extend">
      <a class="navbar-brand" herf="#">
        <div class="navbar-brand-extend">
          <%= link_to image_tag('lantern_lantern_logo.png', width: 50), root_path, class: "logo-img" %>
          <%= link_to image_tag('lantern_lantern_text.png', height: 30), root_path, class: "logo-text" %>
        </div>
      </a>
      <button class="navbar-toggler" data-toggle="collapse" data-target="#menu">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div id="menu" class="collapse navbar-collapse">
        <ul class="navbar-nav navbar-nav-extend ml-auto">
        <% if logged_in? %>
          <li class="nav-item nav-item-extend"><%= link_to "Lantern画面へ", '#', class: "btn btn-info btn-md btn-extend" %></li>
          <li class="nav-item nav-item-extend"><%= link_to "プロフィール", current_user, class: "btn btn-secondary btn-md btn-extend" %></li>
          <li class="nav-item nav-item-extend"><%= link_to "ログアウト", logout_path, method: :delete, class: "btn btn-danger btn-md btn-extend btn-logout-extend" %></li>
        <% else %>
          <li class="nav-item nav-item-extend"><%= link_to "新しくはじめる", signup_path, class: "btn btn-info btn-md btn-extend" %></li>
          <li class="nav-item nav-item-extend"><%= link_to "ログイン", login_path, class: "btn btn-info btn-md btn-extend btn-login-extend" %></li>
        <% end %>
        </ul>
      </div>
    </nav>
  </div>
</header>
app/assets/stylesheets/application.scss.erb
@import "bootstrap";

// 中略

// max-width = 768px
@media (max-width: 768px) {
  .nav-item-extend {
    margin-top: 0.6rem;
  }
}

// button
.btn-extend {
  width: 8.5rem;
}

// header
.navbar-extend {
  margin-bottom: 0;
  background-color: $lantern-black;
  opacity: 0.9;
  ul li a button {
    padding: 0.375rem 1.5rem;
  }
}

.logo-img:hover {
  opacity: 0.6;
}

.logo-text {
  padding-top: 2px;
  :hover {
    opacity: 0.6;
  }
}

.nav-item-extend {
  > a:not(.btn-extend) {
    // text-decoration: none;
    &:hover {
      opacity: 0.6;
    }
  }
  > a {
    margin-left: 1rem;
    float: right;
  }
}

.nav-link {
  color: $lantern-dark-white;
}

// 中略

Bootstrapでは〇〇px以下という指定が表現しづらいです。
その際は、cssで行います。

@media (max-width: 768px) {
  // 処理
}

参考になりました↓
Navbar - Bootstrap(公式)

テストを作る

ログイン/ログアウト機能のテストを作ります。
ここでの手順は以下の通りです。

  • FactroyBotを導入する
  • is_logged_in?メソッドを作る
  • ログイン/ログアウト機能のテストを作る
  • ヘッダーのボタンのテストを作る

FactroyBotを導入する

テスト用データの作成にFactroyBotを使います。
まず、Gemを追加します。

Gemfile
group :development, :test do
+ gem "factory_bot_rails"
end
shell
$ bundle install

次に、RSpec内で記述が省略できるよう、設定を変更します。

spec/rails_helper.rb
# 中略
# コメントアウトを外す
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
# 中略
RSpec.configure do |config|
  # 以下を追加
  config.include FactoryBot::Syntax::Methods
  # 中略
end

最後に、FactoryBotを生成します。

shell
$ rails g factory_bot:model user

以上で、FactoryBotの導入完了です。

is_logged_in?メソッドを作る

ログインしているかどうかを確かめる、テスト用のメソッドを作ります。

以前、Sessionsヘルパーにlogged_in?メソッドを作りました。
しかし、#9からRSpec上でlogged_in?メソッドが使えなくなります。
理由は、クッキー関連のメソッドが呼び出せないからです。
(クッキーについては、#9で解説します。)

logged_in?はcurrent_userを使用します。
current_userはcookies.signedメソッドを使用します。
このメソッドが使用できません。
テスト環境でcookiesのクラスが異なるためです。

よってcookiesに依存しないis_logged_in?メソッドを、RSpecのヘルパーに作ります。
RSpecのヘルパーをつくるには、spec/support配下にファイルを作成します。

shell
$ mkdir spec/support
$ touch spec/support/application_helper.rb
spec/support/application_helper.rb
module ApplicationHelpers

  def is_logged_in?
    !session[:user_id].nil?
  end
end

最後に、先ほどのヘルパーを呼び出せるよう、設定を加えます。

spec/rails_helper.rb
# コメントアウトを外す↓(まだやっていない方)
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
# 中略
RSpec.configure do |config|
  # 追加↓
  config.include ApplicationHelpers

以上でテストに必要なis_logged_in?メソッドが完成です。

参考になりました↓
Railsのインテグレーションテストでcookies.signedを使いたい
RSpecでヘルパーを作成する方法

ログイン/ログアウト機能のテストを作る

ログイン/ログアウト機能のテストを作ります。
ログインしているかどうかは、先ほど作ったis_logged_in?メソッドで確かめます。

spec/requests/users_logins_spec.rb
require 'rails_helper'

RSpec.describe "UsersLogins", type: :request do

  let(:user) { create(:user) }

  describe "GET /login" do
    context "invalid information" do
      it "fails having a danger flash message" do
        get login_path
        post login_path, params: {
          session: {
            email: "",
            password: ""
          }
        }
        expect(flash[:danger]).to be_truthy
        expect(is_logged_in?).to be_falsey
      end
    end

    context "valid information" do
      it "succeeds having no danger flash message" do
        get login_path
        post login_path, params: {
          session: {
            email: user.email,
            password: user.password
          }
        }
        expect(flash[:danger]).to be_falsey
        expect(is_logged_in?).to be_truthy
      end

      it "succeeds login and logout" do
        get login_path
        post login_path, params: {
          session: {
            email: user.email,
            password: user.password
          }
        }
        expect(is_logged_in?).to be_truthy
        delete logout_path
        expect(is_logged_in?).to be_falsey
      end
    end
  end
end

参考になりました↓
undefined method `session' for nil:NilClass in RSpec Rails
RSpecでテストを書いていたら別ファイルのhelperメソッドの呼び出しが undefined method になって困った時
RSpecのfeatureテストでsessionを扱う方法
【動画付き】Railsチュートリアルの統合テスト(integration test)は、RSpecのリクエストスペックに置き換えるのがラクです
【RSpec】システムスペックとリクエストスペックはどう使い分けるの?

ヘッダーのボタンのテストを作る

ヘッダーにある動的なボタンのテストを作ります。
ログイン画面のブラウザテストも更新しています。

/spec/systems/login_spec.rb
require 'rails_helper'

RSpec.describe "Logins", type: :system do

  let(:user) { create(:user) }

  describe "Login" do
    context "invalid" do
      it "is invalid because it has no information" do
        visit login_path
        expect(page).to have_selector '.login-container'
        fill_in 'メールアドレス', with: ''
        fill_in 'パスワード', with: ''
        find(".form-submit").click
        expect(current_path).to eq login_path
        expect(page).to have_selector '.login-container'
        expect(page).to have_selector '.alert-danger'
      end

      it "deletes flash messages when users input invalid information then other links" do
        visit login_path
        expect(page).to have_selector '.login-container'
        fill_in 'メールアドレス', with: ''
        fill_in 'パスワード', with: ''
        find(".form-submit").click
        expect(current_path).to eq login_path
        expect(page).to have_selector '.login-container'
        expect(page).to have_selector '.alert-danger'
        visit root_path
        expect(page).not_to have_selector '.alert-danger'
      end
    end

    context "valid" do
      it "is valid because it has valid information" do
        visit login_path
        fill_in 'メールアドレス', with: user.email
        fill_in 'パスワード', with: 'password'
        find(".form-submit").click
        expect(current_path).to eq user_path(1)
        expect(page).to have_selector '.show-container'
      end

      it "contains logout button without login button" do
        visit login_path
        fill_in 'メールアドレス', with: user.email
        fill_in 'パスワード', with: 'password'
        find(".form-submit").click
        expect(current_path).to eq user_path(1)
        expect(page).to have_selector '.show-container'
        expect(page).to have_selector '.btn-logout-extend'
        expect(page).not_to have_selector '.btn-login-extend'
      end
    end
  end

  describe "Logout" do 
    it "contains login button without logout button" do
      visit login_path
      fill_in 'メールアドレス', with: user.email
      fill_in 'パスワード', with: 'password'
      find(".form-submit").click
      expect(current_path).to eq user_path(1)
      expect(page).to have_selector '.show-container'
      expect(page).to have_selector '.btn-logout-extend'
      expect(page).not_to have_selector '.btn-login-extend'
      click_on 'ログアウト'
      expect(current_path).to eq root_path
      expect(page).to have_selector '.home-container'
      expect(page).to have_selector '.btn-login-extend'
      expect(page).not_to have_selector '.btn-logout-extend'
    end
  end
end

以上でログイン/ログアウト時のテストが完了です。

備考:ジェネレータの種類を確認する

ジェネレータの種類を一覧できる方法を確認します。
「これって、ジェネレータあったかな?」と思う際に便利です。

shell
$ rails g --help

中略 (例:RSpec)
Rspec:
  rspec:controller
  rspec:feature
  rspec:helper
  rspec:install
  rspec:integration
  rspec:job
  rspec:mailer
  rspec:model
  rspec:observer
  rspec:request
  rspec:scaffold
  rspec:view

今回は以上です。


前回:#7 ログイン機能の下準備編
次回:#9 永続セッション, cookie編

aokyo17
rails tutorial → ポートフォリオing. 誰もが経験した初心びくびく20代1年目.. フォローはすぐ返したい厨。
https://komucha.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした