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

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

前回：[#7 ログイン機能の下準備編](https://qiita.com/aokyo17/items/2f71cc0feb795d640c4a)
次回：[#9 永続セッション, cookie編](https://qiita.com/aokyo17/items/c2e4cd1d607746753158)

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

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

# 今回のゴールを把握する
ゴールは、以下の3つです。

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

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

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

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

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

以上です。
（ログイン画面のビューは[#7](https://qiita.com/aokyo17/items/2f71cc0feb795d640c4a)で完成しているため、省略します。）

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

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

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

```rb: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の使い方まとめ](https://www.sejuku.net/blog/33677)

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

```rb: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](https://qiita.com/aokyo17/items/2f71cc0feb795d640c4a)でも使いましたが、解説をしていませんでした。
よって、ここで取り上げます。
両者には、以下のような違いがあります。

**find**

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

**find_by**

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

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

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

参考になりました↓
[find、find_by、whereの違い](https://qiita.com/tsuchinoko_run/items/f3926caaec461cfa1ca3)
[find(42)とfind_by(id: 42)の違いについて](https://qiita.com/SpicyCoffee/items/bceebbd8b7524d84ab5e)

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

```rb:
@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の場合、処理が終了する
* ||は、左から順に判定する

```rb:
@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])という書き方について](https://qiita.com/aiandrox/items/f4215471f40325d69523)

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

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

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

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

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

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

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

# Sessionsコントローラーを作る
ログイン/ログアウトを関係する、Sessionsコントローラーを作ります。
[#7](https://qiita.com/aokyo17/items/2f71cc0feb795d640c4a)で作ったコントローラーの拡張になります。

```rb: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

```

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

```rb: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/454653/bf98e57d-4100-8713-bd39-8d732c7b8691.gif)

```erb: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>
```

```scss: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で行います。

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

参考になりました↓
[Navbar - Bootstrap（公式）](https://getbootstrap.com/docs/4.3/components/navbar/)

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

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

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

```diff:Gemfile
group :development, :test do
+ gem "factory_bot_rails"
end
```

```shell:shell
$ bundle install
```

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

```rb: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:shell
$ rails g factory_bot:model user
```

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

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

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

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

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

```shell:shell
$ mkdir spec/support
$ touch spec/support/application_helper.rb
```

```rb:spec/support/application_helper.rb
module ApplicationHelpers
  
  def is_logged_in?
    !session[:user_id].nil?
  end
end
```

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

```rb: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を使いたい](https://medium.com/@petitviolet/rails%E3%81%AE%E3%82%A4%E3%83%B3%E3%83%86%E3%82%B0%E3%83%AC%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%83%86%E3%82%B9%E3%83%88%E3%81%A7cookies-signed%E3%82%92%E4%BD%BF%E3%81%84%E3%81%9F%E3%81%84-6b046e1331d6)
[RSpecでヘルパーを作成する方法](https://breakthrough-tech.yuta-u.com/rspec/how-to-make-spec-support/)

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

```rb: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](https://stackoverflow.com/questions/20872121/undefined-method-session-for-nilnilclass-in-rspec-rails)
[RSpecでテストを書いていたら別ファイルのhelperメソッドの呼び出しが undefined method になって困った時](https://qiita.com/mm36/items/5b536aa001b5e68cdc83)
[RSpecのfeatureテストでsessionを扱う方法](https://qiita.com/Yuki_Nakagami/items/85e96075f943ebd9c259)
[【動画付き】Railsチュートリアルの統合テスト(integration test)は、RSpecのリクエストスペックに置き換えるのがラクです](https://qiita.com/jnchito/items/8d8d579cdca131c0db14)
[【RSpec】システムスペックとリクエストスペックはどう使い分けるの？](https://qiita.com/tofuonfire/items/cc45eb35704d898ee592)

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

```rb:/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: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 ログイン機能の下準備編](https://qiita.com/aokyo17/items/2f71cc0feb795d640c4a)
次回：[#9 永続セッション, cookie編](https://qiita.com/aokyo17/items/c2e4cd1d607746753158)