【ハンズオン】Ruby on Rails 7 実践開発入門ガイド!現場レベルまでスキルアップ

当サイトでは一部リンクに広告が含まれています
アイキャッチ

本ガイドでは、プログラミング初心者から現場レベルになるために必要な知識を、Ruby on Rails アプリを作りながら学ぶことができます。

Ruby on Rails 7 の開発環境構築から始め、ユーザー認証機能 (Devise) の導入、RSpecを用いた単体テスト、Capybaraによる統合テストの実装、そしてRubocopを用いたコードの品質管理まで、現場で必要とされる知識を網羅しています。

さらに、人気のCSSフレームワークであるTailwind CSSを用いて整った見た目の UI を作る方法も紹介します。

単なる知識の習得だけでなく、実際に一つのアプリケーションを開発する経験を通じて Ruby on Rails のより実践的な開発手法を身につけていきましょう!

長く、ボリュームのある内容とはなっていますが、丁寧で初心者向けの解説になっています。
少しずつ進めていけば必ず完成に近づきますのでご安心ください!

補足

本ハンズオンは、 Zenn で有料販売している本の無料公開版です。

https://zenn.dev/tmasuyama1114/books/ab51fea5d5f659

読書履歴の保存や、ソースコードの見やすさという観点で Zenn では有料販売しておりますが、無料公開版でも内容自体は変わりません。

なお、本ハンズオンは Ruby と Ruby on Rails をある程度学んだことがある人が対象となっております。

まだ学習が済んでいない人は、以下のロードマップに沿って Ruby on Rails を学ぶと、より効率的にスキルを身につけられます!

目次

今回作成するアプリのイメージ

作っていくアプリは以下のURLに公開しているので、こちらでイメージを掴んでみてください。
※無料サーバで公開しているため、タイミングによってはアクセスに時間がかかることがあります。

https://techlog-app-0fco.onrender.com

ユーザー登録画面デモ
学習ログの投稿画面デモ
ユーザー登録画面デモ

本ガイドの特徴

本ガイドでは、Ruby on Rails でアプリケーションを作成するための環境構築から、テスト駆動開発、ユーザー認証機能の実装までを学びます。

  • 対象読者
    • Rails を使ったことはあるけどまだ不安がある方
    • ユーザー作成機能を使ったアプリを作りたい方
    • テストを書いたことがない方
    • テスト駆動開発に興味がある方
  • 学べること
    • Ruby on Rails を用いたアプリ作成
    • ユーザー認証機能 (devise)
    • RSpec を用いたテスト
    • テスト駆動開発
    • CSSフレームワークの使い方 (Tailwind CSS)
    • 綺麗でバグの少ないコードの書き方
    • Linter ツール(静的解析ツール)の使い方 (Rubocop)
  • 環境情報
    • Ruby: 3.0.4
    • Ruby on Rails: 7.0系
  • 事前に必要な準備
    • GitHub アカウントの作成

また、各 Chapter の最後では「宿題」として主に参考サイトを読んでいただくタイミングを設け、より体型的かつ深く理解いただくことを重視しています。
これにより「なんとなく理解した」状態を脱却していただき、「使える」知識として身に付けていただけます。

なお、ソースコードは GitHub にて公開しています。

https://github.com/tmasuyama1114/techlog-app

まずはここから:Ruby 環境構築

この章では、お使いのパソコン (ローカル環境) で Ruby を使用するための設定をしていきます。

実行環境についての注意

本カリキュラムで使用する Ruby のバージョンは 3.0.4 ですので、ローカルにインストールしていきましょう。

他のバージョンでも動作する場合もありますが、予期せぬエラーを避けるためにも
Ruby だけでなく gem もバージョンを合わせていただくようお願いします。

なお、本カリキュラムでコマンドを実行する際には Mac ではターミナル、または Windows では GitBash でのコマンド実行を想定しております。

そのため、Mac の方はターミナル、Windows の方は Git Bash を使えるようにしておくとカリキュラムに沿った学習がスムーズになります。

作業用ディレクトリ作成

お好きなディレクトリに、作業する場所として作業用ディレクトリを作成しておきます。
どこでも OK ですが、ここではホームディレクトリに workspace という名前でディレクトリを作成してみましょう。

Mac の方はターミナル、Windows の方は Git Bash を開いて次のコマンドを実行します。

$ cd ~
$ mkdir workspace
$ cd workspace

ここからの作業はすべてこのディレクトリで行っていきます。

Ruby インストール (Mac を使用している場合)

Mac ユーザーの場合、rbenv 複数のバージョンを切り替えることが出来る rbenv を使用します。

rbenv インストール

まだ rbenv をインストールしていない方は、こちらの記事を参考に rbenv をインストールしてください。
Homebrew を使ってインストールしますが、もし Homebrew もインストールしていない場合はこの手順で少し時間がかかるかと思います。

※Ruby のインストールを行う手前 (source ~/.bash_profile実行) までで OK です。

Ruby 3.0.4 インストール

rbenv のインストールが終わったら、Ruby のバージョンを指定してインストールします。

以前から rbenv をインストールしていた方は、rbenv と ruby-build のアップグレードを行いましょう。
これらのバージョンが古いと、今回必要な Ruby 3.0.4 をインストールできない場合があるためです。
※このタイミングで rbenv をインストールした方は不要です。

$ brew upgrade rbenv ruby-build

rbenv と ruby-build のアップグレードが終わったら、次のコマンドを実行して Ruby 3.0.4 をインストールします。

$ rbenv install 3.0.4

バージョン切り替え

特にエラー無くインストールが完了したら、workspace ディレクトリ配下で使用する Ruby のバージョンを固定します。
バージョンを固定するには rbenv local コマンドを使用します。

$ rbenv local 3.0.4

バージョン確認

最後にバージョンを確認するコマンドを実行し、想定したバージョンの Ruby を使えるようになっていることを確認します。
出力内容は環境によって多少異なりますが ruby 3.0.4 という文字列が表示されていれば成功です。

$ ruby -v
ruby 3.0.4p208 (2022-04-12 revision 3fa771dded) [arm64-darwin21]

Ruby インストール (Windows を使用している場合)

Windows ユーザーの場合は RubyInstaller というインストーラーを使用します。

Ruby 3.0.4 インストール

具体的な手順についてはこちらのサイトを参考に、Ruby 3.0.4 用 RubyInstalelr を使用してインストールします。
※参考サイトでは Ruby 2.6.5 での手順になっていますので、3.0.4 と読み替えてください。

バージョン確認

RubyInstaller でのインストールが完了したら最後に ruby -v をコマンドを Git Bash で実行します。
環境によって出力内容は多少異なりますが、Ruby 3.0.4 という文字列が表示されていれば成功です。

$ ruby -v
ruby 3.0.4

ライブラリを管理する:Bundler の準備

今回は、便利な gem というライブラリの依存関係を解決してくれる Bundler について解説、および設定していきます。

Bundler の概要

Ruby では便利なライブラリが多数存在しており、それらはgemといいます。
本カリキュラムで使用する Ruby on Rails というフレームワークも gem の一つです。

それ以外にも多数の gem を使用することになりますが、
ある gem を使用するためには他の gem が必要であり、
しかもそのバージョンは x.x.x 以上で… といった依存関係を管理しなければいけません。

こういった面倒な依存関係を管理するために使用するのが、この章でインストールする Bundler です。
(Bundler 自体も gem の一つです。)

Bundler は開発現場では必ずといっていい程使用する gem ですので、
この後出てくる GemfileGemfile.lock を含めて知っておくべき概念です。

是非、手順を進める前にこちらの参考サイトをご一読ください。
多少ボリュームはありますが、開発現場に入るかもしれない方は方は読んでおくことをおすすめします。

Bundler インストール

Ruby をインストールしてさえいれば、gem install コマンドで好きな gem をインストールします。
ターミナル (Git Bash) を開き、次のコマンドを実行します。
ここではバージョンを指定して Bundler をインストールしましょう。

$ gem install bundler -v "2.3.15"
Fetching bundler-2.3.15.gem
Successfully installed bundler-2.3.15
Parsing documentation for bundler-2.3.15
Installing ri documentation for bundler-2.3.15
Done installing documentation for bundler after 1 seconds
1 gem installed

インストールできたら、指定したバージョンがインストールできているかを確認します。

$ bundler -v
Bundler version 2.3.15

Gemfile 作成

Bundler を使うときには予め Gemfile という、インストールしたい gem の情報をまとめたファイルを作成します。
直接作成することもできますが、bundle init コマンドで作成しましょう。
(以前に作成した workspace ディレクトリで実行します。)

$ cd ~/workspace
$ bundle init

上記のコマンドを実行したディレクトリ (workspace) 内に
Gemfile というファイルが作成されていることを確認してください。

作成された Gemfile の中身を VSCode 等、お好きなエディタで開き、以下のように編集します。

source 'https://rubygems.org'

gem 'rails', '~> 7.0.3'

Gemfile では上記のように、インストールしたい gem (ここでは rails) とそのバージョンを指定しています。

バージョンを何も書かなければ常に最新版がインストールされますが、
上記の書き方では rails 7.0.x の最新をインストールすることになります。

バージョン指定方法などについて興味がある方は、こちらの記事を参考にしてください。

gem のインストール (bundle install)

Gemfile を workspace ディレクトリ内に用意できたら、Gemfile の定義に従って gem をインストールします。

インストールするコマンドは bundle install というコマンドですが、
ローカルの環境を汚さないように workspace ディレクトリ内に gem をインストールすることをおすすめします。

そのためのコマンドを使用し、/workspace/.bundle ディレクトリ内にインストールするよう設定します。

$ bundle config set path '.bundle'

設定できたら、bundle install コマンドを実行します。

$ bundle install
...
Bundle complete! xx Gemfile dependencies, xxx gems now installed.

このコマンドはお使いのPCによっては稀に失敗する場合があり、
初学者の方がつまずきやすいポイントなので注意しましょう。

ターミナルにBundle complete!と表示されていれば問題なくインストールできていますが、
そうでなければ何かしらのエラーで失敗しています。

原因は様々ですのでまずはエラーメッセージで Google 検索して調べるのが近道です。

インストールした gem を実行

さて、/.bundleディレクトリ内に gem がインストールされました。
これで workspace ディレクトリ内ではインストール済みの gem を使用することができます。

ただし、Bundler で依存関係を解決しながらインストールした gem をコマンドで使用する際は、
gem 専用コマンドの前に bundle exec を付ける必要があるので注意してください。

例えば rails のバージョンを確認するには通常 rails -v と実行しますが、今回の場合は bundle exec rails -v と実行します。

$ bundle exec rails -v
Rails 7.0.3.1

なお、このときに Gemfile が見つからない旨のメッセージが表示されたら、実行するディレクトリが間違っているか、gem を正しくインストールできていない可能性があります。
切り分けの参考にしてみてください。

$ bundle exec rails -v
Could not locate Gemfile or .bundle/ directory

プロジェクトを準備しよう:Ruby on Rails のセットアップ

いよいよこの章では、Ruby on Rails (以下、Rails)のアプリケーションを作成します。

アプリの概要

このカリキュラムでは、みなさんがプログラミング学習の記録をつけて
シェアすることができる TechLog というアプリを作成します。

TechLog では学習の記録 (以下、学習ログ) の投稿、詳細、削除が可能であり、
devise という gem を用いたユーザー認証機能も搭載しています。

また、見た目については最近流行している CSS フレームワークの一つである Tailwind Css を使用します。
(実際、著者が働いている現場でも CSS については Tailwind CSS で管理しています。)

Tailwind CSS は Rails 7.0 以降のバージョンでは標準搭載されており、
複雑な設定をすることなく最初から使用できるため Rails と相性が良いフレームワークです。

後述しますが、Tailwind CSS を使用には rails new を実行する際にオプションを付けるだけで済みます。

Rails インストール確認

Rails の gem 自体は前の章で Bundler を用いて workspace ディレクトリ内にインストールできているはずです。
今一度、workspace ディレクトリで Rails をインストールできていることを確認しましょう。

$ cd ~/workspace
$ bundle exec rails -v
Rails 7.0.3.1

Rails プロジェクト作成

Rails で新しいプロジェクト(各種ファイル一式)を作成するには
rails newコマンドを workspace ディレクトリで使用します。

アプリケーションの名前は TechLog なので、ディレクトリ名としては techlog-app と指定します。
また、先述の通り今回は CSS フレームワークとして Tailwind CSS を使用するので
--css tailwind というオプションを付けましょう。

$ bundle exec rails new techlog-app --css tailwind
      create
      create  README.md
      create  Rakefile
      ...
Using bootsnap 1.12.0
Bundle complete! 16 Gemfile dependencies, 75 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.

この時、ご利用の環境により cannot load such file -- bootsnap/setup (LoadError) という
以下のようなエラーメッセージが出力される場合があります。

         run  bundle binstubs bundler
       rails  importmap:install
/Users/YourName/workspace/techlog-app/config/boot.rb:4:in `require': cannot load such file -- bootsnap/setup (LoadError)

しかし、この後の手順で対応していくので今は気にしなくて大丈夫です。

rails プロジェクトの gem インストール

さて、これで Rails プロジェクト techlog-app が作成されました。
現在のディレクトリは workspace ですので、techlog-app ディレクトリに移動しましょう。

$ cd techlog-app/

次に、Rails プロジェクトを動かすために必須となる gem のインストールを行います。
必要な gem は rails new した時に Gemfile が自動生成されているので、
前の章と同じ手順で bundle install を実行するだけです。

最初に gem のインストール先 (パス) を指定しましょう。

$ bundle config set path '.bundle'

少しややこしいですが、techlog-app ディレクトリ内で /.bundleディレクトリを指定しているため
/workspace/techlog-app/.bundle ディレクトリに gem をインストールすることになります。

パスを指定できたら、bundle install を実行します。`

$ bundle install
Fetching gem metadata from https://rubygems.org/..........
Fetching rake 13.0.6
Installing rake 13.0.6
...
Fetching rails 7.0.3
Installing rails 7.0.3
Bundle complete! 16 Gemfile dependencies, 75 gems now installed.
Bundled gems are installed into `./vendor/bundle`
Post-install message from rubyzip:
RubyZip 3.0 is coming!
**********************

The public API of some Rubyzip classes has been modernized to use named
parameters for optional arguments. Please check your usage of the
following classes:
  * `Zip::File`
  * `Zip::Entry`
  * `Zip::InputStream`
  * `Zip::OutputStream`

Please ensure that your Gemfiles and .gemspecs are suitably restrictive
to avoid an unexpected breakage when 3.0 is released (e.g. ~> 2.3.0).
See https://github.com/rubyzip/rubyzip for details. The Changelog also
lists other enhancements and bugfixes that have been implemented since
version 2.3.0.

上記のように Bundle complete! を含んだメッセージが出ていれば OK です。

Import maps インストール

Rails 6 では JavaScript をまとめて取り扱うためのバンドラとして webpacker が標準でしたが
、Rails 7 から Import maps が標準となっています。

本カリキュラムでは JavaScript をゴリゴリ実装するわけではないため、
今の時点では詳しく知らなくても問題ありませんが、余裕があれば参考サイトも合わせてお読みください。

Import maps を Rails アプリにインストールするには rails importmap:installコマンドを使います。

techlog-app ディレクトリ内で次のコマンドを実行しましょう。
※もし rails new時に importmap:install のエラーが出ていなければ本手順は不要です。

$ bundle exec rails importmap:install
Add Importmap include tags in application layout
      insert  app/views/layouts/application.html.erb
Create application.js module as entrypoint
      create  app/javascript/application.js
Use vendor/javascript for downloaded pins
      create  vendor/javascript
      create  vendor/javascript/.keep
Ensure JavaScript files are in the Sprocket manifest
      append  app/assets/config/manifest.js
Configure importmap paths in config/importmap.rb
      create  config/importmap.rb
Copying binstub
      create  bin/importmap

上記のように各ファイルが作成されており、特にエラーが出ていなければ OK です。

Tailwind CSS インストール

続いて Tailwind CSS を Rails アプリにインストールします。

Tailwind CSS も Rails 7.0 以降では標準となっているため、railsコマンドを使ってインストールできます。
※もし rails new時に tailwindcss:install のエラーが出ていなければ本手順も不要です。

$ bundle exec rails tailwindcss:install
Add Tailwindcss include tags and container element in application layout
      insert  app/views/layouts/application.html.erb
      insert  app/views/layouts/application.html.erb
      insert  app/views/layouts/application.html.erb
Build into app/assets/builds
      create  app/assets/builds
      create  app/assets/builds/.keep
      append  app/assets/config/manifest.js
      append  .gitignore
Add default config/tailwindcss.config.js
      create  config/tailwind.config.js
Add default app/assets/stylesheets/application.tailwind.css
      create  app/assets/stylesheets/application.tailwind.css
Add default Procfile.dev
      create  Procfile.dev
Ensure foreman is installed
         run  gem install foreman from "."
Fetching foreman-0.87.2.gem
Successfully installed foreman-0.87.2
Parsing documentation for foreman-0.87.2
Installing ri documentation for foreman-0.87.2
Done installing documentation for foreman after 0 seconds
1 gem installed
Add bin/dev to start foreman
      create  bin/dev
Compile initial Tailwind build
         run  rails tailwindcss:build from "."
...
Done in 176ms.

こちらも、上記のように各ファイルが作成されており、特にエラーが出ていなければ OK です。

動作確認 (rails s)

ここまで準備ができたら、最後に Rails サーバを起動して最低限の動作確認をしておきましょう。

Rails サーバを起動するには bundle exec rails s コマンドを実行します。

$ bundle exec rails s
=> Booting Puma
=> Rails 7.0.3 application starting in development
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 5.6.4 (ruby 3.0.4-p208) ("Birdie's Version")
*  Min threads: 5
*  Max threads: 5
*  Environment: development
*          PID: 64014
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
Use Ctrl-C to stop

上記のようなメッセージが表示されたら、ブラウザで http://127.0.0.1:3000/ にアクセスします。
以下のように Rails 7.0 以降のランディングページが表示されることを確認してください。

img

確認が終わったら Ctrl + C を押して、Rails サーバを停止させておきます。

補足:bin/rails コマンドについて

Rails プロジェクトの中に bin/というディレクトリが作成されています。
この中に rails というファイルがあり、rails s などの rails コマンドはこのファイルを使用しています。

このように bin/ ディレクトリ内に存在するファイルを通して実行される処理の場合、
実は bundle exec を付けずにbin/rails という形式で実行できます。

また、bin/rails コマンドで Rails サーバを起動する場合は spring という、
開発環境での起動高速化の仕組みを利用して実行できるため、開発上のメリットがあります。

ただ、この spring というのは便利な一方で厄介な存在でもあるため注意が必要です。

例えばコードを修正してもすぐに反映されなかったり、rails コマンドが
正常に実行できない場合があったりと、慣れていないと沼にハマってしまう場合があります。

そのため、本カリキュラムでは bin/rails コマンドを使わずに bundle exec rails コマンドを実行していきます。
(一部コマンドについては bin/xxx を使用する場合もあります。)

起動の高速化という恩恵は受けられないものの、本カリキュラムでは重い処理を実行するわけではなく、
大きな差は生まれないためご安心いただければと思います。

コードの履歴管理・共有:GitHub リポジトリ設定

ここからの作業については、変更内容を後ほど追えるようにするために
Git、そして GitHub で管理していきましょう。

なお、人によっては SSH 設定など、一部については済んでいる場合があるので適宜飛ばしていただいて構いません。
その場合、techlog-app 用のリモートリポジトリ作成だけ実施してください。

Git とは

Git はファイルのバージョン(履歴)を管理するシステムのです。

ソースコードを変更する開発を進めている時、変更をしすぎて正常に動作しなくなってしまうことがあると思います。
こういった時に役立つのが、正常に動作していた途中の状態を
一つのバージョンとして保存しておき、後で復元できる機能を持つ Git です。

Git は個人で開発する際に役立つのはもちろん、
ソースコードのバージョンを細かく他の人に共有することができるため、チームでの開発をする時に活躍します。

今やどの現場においても Git を使うことは当たり前のようになっていますが、
未経験からエンジニアとなった方がつまずきやすいポイントでもあります。
この機会にしっかりと使いこなし、エンジニアの一歩を踏み出していきましょう。

GitHub とは

GitHub は、Git を用いてバージョン管理されたソースコードを共有するためのプラットフォームです。

Git ではリポジトリと呼ばれる箱のようなものでソースコードを管理していきます。

自分のパソコン上で作成したリポジトリ(=ローカルリポジトリ) を、
GitHub というオンライン上のリポジトリ (=リモートリポジトリ) にあげておくことで、
チームメンバーが閲覧したり、さらには AWS や Slack といった
外部サービスと連携して外部に公開したりといったことまで出来ます。

本カリキュラムにおける設定方法はこの後ご紹介しますが、
事前に全体的な流れや、基本的な使い方は以下の参考サイトで事前に把握しておいてください。

初期設定・リモートリポジトリ作成

まずは以下サイトの手順に沿って GitHub にリモートリポジトリ作成、および SSH の設定までを完了させましょう。
※1: [GitHub に SSH の設定をする] まで完了させ、[GitHub にプッシュをする]には進まないでください。
※2: リポジトリは Private ではなく Public に設定してください。

Mac の方はこちら

Windows の方はこちら

※上記サイトの手順 [GitHub の設定] > [リモートリポジトリの作成] で
リモートリポジトリを作成する際、リポジトリ名は techlog-app にしておきましょう。

リモートリポジトリを作成した後は、以下のような画面が GitHub では表示されているはずです。

techlog-app を GitHub で管理

それでは、前章で作成した Rails プロジェクト techlog-app を、
GitHub のリモートリポジトリにプッシュ(アップロード)するための準備をします。

リモートリポジトリ設定

まずターミナルで techlog-app ディレクトリに移動しましょう。

$ cd ~/workspace/techlog-app
$ pwd # 場所の確認
/Users/YourName/workspace/techlog-app

techlog-app ディレクトリ内に移動できたことを確認したら、techlog-app のアップロード先であるリモートリポジトリの場所を指定します。
(xxxxxxには GitHub のアカウント名を指定してください。)

$ git remote add origin git@github.com:xxxxxx/techlog-app.git

なお、上記コマンドは GitHub でリモートリポジトリを作成した時に表示されたコマンドの一部です。

image

リモートリポジトリを正常に設定できたら、
りモートリポジトリ確認用コマンド git remote -vを叩いた時に表示されます。

$ git remote -v
origin  git@github.com:tmasuyama1114/techlog-app.git (fetch)
origin  git@github.com:tmasuyama1114/techlog-app.git (push)

ブランチ設定

Git ではブランチというもので、作業状況を分けて(分岐して)管理することができます。

※本カリキュラムでは main というブランチだけで作業していきますが、
実際の現場では新機能追加や、何らかの設定を行うごとにブランチを一つ作成するのが普通です。

以下のコマンドを techlog-app ディレクトリでそのまま実行します。

$ git branch -M main

これでブランチとして main が設定されました。

変更内容をステージングへ登録

Git では、ファイルの変更をしただけではバージョン管理対象になりません。

ステージングという場所に変更内容を登録することで
初めて「これらをこれからバージョン管理します」と宣言することになります。

現在の状態でgit statusコマンドを実行してみてください。
Untrackad files:という欄に表示されているのが、
まだステージングに登録されていない変更内容(ファイル)です。

$ git status
On branch main

No commits yet

Untracked files:
(use "git add <file>..." to include in what will be committed)
.gitattributes
.gitignore
.ruby-version
Gemfile
Gemfile.lock
...

nothing added to commit but untracked files present (use "git add" to track)

それでは、Rails プロジェクトを作成したというここまでの変更内容をステージングに登録しましょう。
変更内容をまとめてステージングに登録するには git add .コマンドを実行します。

$ git add .

これで変更内容がステージングに登録されたので
git statusコマンドを実行すると表示が少し変わっています。

$ git status
On branch main

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
        new file:   .gitattributes
        new file:   .gitignore
        new file:   .ruby-version
        new file:   Gemfile
        new file:   Gemfile.lock
        ...

変更内容をコミット

さて、ステージングに登録した変更内容はコミット(commit)することで初めて、変更内容が記録されます。
(このコミット 1 回につき、一つのバージョンが作られて後から遡ることが出来るようになります。)

先ほどステージングに登録した変更内容について、メッセージ付きでコミットしてみましょう。

$ git commit -m "プロジェクト開始"
[main (root-commit) c5600b7] プロジェクト開始
 80 files changed, 1400 insertions(+)
 create mode 100644 .gitattributes
 create mode 100644 .gitignore
 create mode 100644 .ruby-version
 create mode 100644 Gemfile
 create mode 100644 Gemfile.lock
 ...

コミットをリモートリポジトリにプッシュ

コミットしたことで、変更内容を一つのバージョン(スナップショット)として記録できました。
この記録を GitHub のリモートリポジトリに共有し、
リモートリポジトリとローカルリポジトリを同じ状態に同期するにはプッシュを実行します。

プッシュについてはイメージ図があると理解しやすいので、こちらの参考サイトを一度ご覧ください。

$ git push -u origin main
Enter passphrase for key '/Users/YourName/.ssh/id_rsa':
...

- [new branch] main -> main
  Branch 'main' set up to track remote branch 'main' from 'origin'.

途中、GitHub の設定時に作成した SSH 鍵のパスワードを求められるので、ターミナル上で入力してください。

リモートリポジトリの確認

git push を実行したことで、GitHub にこれまでの変更内容が同期されています。
GitHub の画面を更新し、techlog-app リポジトリの状況を確認してみましょう。

先ほどのコミットが反映されているはずです。

今後の作業ではこのように、区切り毎にコミットしていくようにします。

自分の作業管理に役立つのはもちろん、
トラブルシューティングで他の人のサポートを受ける時にコードを共有することで出来るので役立ちます。

宿題

git の基本操作と概念

git では複数の操作を一気に行うため、その意味を理解していないと混乱しやすいところです。
今回のカリキュラムで一旦操作の流れはなんとなく掴めたと思うので、
リポジトリの種類や処理のイメージについてこちらの参考サイトで掴んでおきましょう。

イラスト付きなのでイメージしやすいですよ。

ブランチについて

git を使った開発では、ブランチという分岐を頻繁に作成しながら開発を進めていきます。
そのため、このタイミングでしっかりとブランチの概念と、操作方法について理解しておきましょう。

クリーンなコードを維持しよう:Rubocop の導入

今回は Rubocopという gem を利用し、ソースコードの改善ポイントを検査するための設定を行います。

Rubocop とは?

Rubocop とは、ソースコード中の無駄なインデントの有無や、Ruby で推奨されない書き方をしていた場合に指摘してくれる生活指導のようなツールです。
複数人で開発を行っていても書き方を統一するというメリットがあるため、チーム開発を行う多くの現場で導入されています。

また、簡単な修正で済むような指摘であれば半自動的に修正できることも Rubocop の特徴です。

導入

Rubocop は gem であるため、Gemfile に書き込んで bundle installを実行するとインストールできます。

techlog-app 内の Gemfile を開き、group :developmentという欄に以下のように追記してください。
本カリキュラムでは、本体である rubocopの他に Rails 用、パフォーマンスチェック用、RSpec(テスト)用の rubocop も使用します。

group :development do
  ...
  gem 'rubocop', require: false # 追加
  gem 'rubocop-performance', require: false # 追加
  gem 'rubocop-rails', require: false # 追加
  gem 'rubocop-rspec' # 追加
end

Gemfile に追記したら bundle install を実行します。

$ bundle install
...
Fetching rubocop 1.30.1
Installing rubocop 1.30.1
Bundle complete! xx Gemfile dependencies, xx gems now installed.

これで Rubocop が使えるようになりました。

次に、Rubocop の設定ファイルを作成します。

設定ファイルの準備

Rubocop では .rubocop.yml という名前のファイルに書かれた設定に従い、ソースコードの検査を行います。
例えば「このファイルは検査対象外としたい」「デフォルトの検査は厳しすぎる」といった時に、この設定ファイルにより適宜調整を行うことが可能です。

また、チーム開発ではこの設定ファイルを共有しておくことで、チーム内でソースコードの品質を一律に保つことができます。

それでは設定ファイルを準備しましょう。
まずは新規作成するため、Rails プロジェクトのルートディレクトリ (techlop-app ディレクトリの直下) で以下のコマンドを実行します。

$ touch .rubocop.yml

作成できたら、VSCode などのエディタで編集して中身を以下のように設定します。

.rubocop.yml

require: rubocop-rails

AllCops:
  # 除外するディレクトリ(自動生成されるファイル)
  Exclude:
    - "vendor/**/*"
    - "db/**/*"
    - "config/**/*"
    - "bin/*"
    - "node_modules/**/*"

  # 新ルールを有効化
  NewCops: enable

# 1行あたりの文字数をチェックする
Layout/LineLength:
  Max: 130
  # 下記ファイルはチェックの対象から外す
  Exclude:
    - "Rakefile"
    - "spec/rails_helper.rb"
    - "spec/spec_helper.rb"

# RSpecは1つのブロックあたりの行数が多くなるため、チェックの除外から外す
# ブロック内の行数をチェックする
Metrics/BlockLength:
  Exclude:
    - "spec/**/*"

# Assignment: 変数への代入
# Branch: メソッド呼び出し
# Condition: 条件文
# 上記項目をRubocopが計算して基準値を超えると警告を出す(上記頭文字をとって'Abc')
Metrics/AbcSize:
  Max: 50

# メソッドの中身が複雑になっていないか、Rubocopが計算して基準値を超えると警告を出す
Metrics/PerceivedComplexity:
  Max: 8

# 循環的複雑度が高すぎないかをチェック(ifやforなどを1メソッド内で使いすぎている)
Metrics/CyclomaticComplexity:
  Max: 10

# メソッドの行数が多すぎないかをチェック
Metrics/MethodLength:
  Max: 30

# ネストが深すぎないかをチェック(if文のネストもチェック)
Metrics/BlockNesting:
  Max: 5

# クラスの行数をチェック(無効)
Metrics/ClassLength:
  Enabled: false

# 空メソッドの場合に、1行のスタイルにしない NG例:def style1; end
Style/EmptyMethod:
  EnforcedStyle: expanded

# クラス内にクラスが定義されていないかチェック(無効)
Style/ClassAndModuleChildren:
  Enabled: false

# 日本語でのコメントを許可
Style/AsciiComments:
  Enabled: false

# クラスやモジュール定義前に、それらの説明書きがあるかをチェック(無効)
Style/Documentation:
  Enabled: false

# %i()構文を使用していないシンボルで構成される配列リテラルをチェック(無効)
Style/SymbolArray:
  Enabled: false

# 文字列に値が代入されて変わっていないかチェック(無効)
Style/FrozenStringLiteralComment:
  Enabled: false

# メソッドパラメータ名の最小文字数を設定
Naming/MethodParameterName:
  MinNameLength: 1

今回の .rubocop.yml は一例です。
チームや開発方針によってルールが変わってくるため、入った現場の .rubocop.yml を使うようにしましょう。

では、ここまでで一旦コミットしておきます。

$ git add .
$ git commit -m "Rubocopの初期設定を完了"

Rubocop を実行

それでは、これで Rubocop によるソースコードの静的解析(検査)を行えるようになったので実行してみましょう。
実行するには、以下のコマンドを使います。

$ bundle exec rubocop

実行すると、以下のような内容がターミナルに出力されたはずです。

$ bundle exec rubocop
Inspecting 13 files
CC.....C.CCCC

Offenses:
...
test/test_helper.rb:3:9: C: [Correctable] Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.
require "rails/test_help"
        ^^^^^^^^^^^^^^^^^

13 files inspected, 35 offenses detected, 35 offenses autocorrectable

上記の出力内容をもとに、簡単に解説します。

test/test_helper.rb:3:9: C: [Correctable] Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.
require "rails/test_help"
        ^^^^^^^^^^^^^^^^^
13 files inspected, 35 offenses detected, 35 offenses autocorrectable

解説:

  • test/test_helper.rb:3
    • 修正が望ましいファイルとその行
  • [Correctable]
    • 自動で修正可能かどうか(後述)
  • Style/StringLiterals
    • 指摘の種類:ここでは、コードスタイルに関する指摘
  • Prefer single-quoted strings when you don't need string interpolation or special symbols.
    • 指摘対照となった理由と、改善の方針:ここでは、文字列を囲うには " よりも ' を使うべきという指摘
  • 35 offenses detected
    • 指摘の数:ここでは 35 ヶ所の指摘
  • 35 offenses autocorrectable
    • 指摘のうち自動修正が可能な数:ここでは 35 ヶ所が自動修正可能

それでは、次はこれらの指摘事項に従って修正をしましょう。

-a オプションで自動修正

今回の指摘事項を確認してみると、ほとんどがダブルクォーテーション " を使っていることに関する指摘です。
それ以外は Do not use spaces inside percent literal delimiters.という、空白の使い方に関する指摘でした。

さて、このようにコードスタイルに関する指摘というのは何も考えずに修正できますが、ファイル数も多く一つひとつ書き換えていくのは大変ですよね。

このように修正後の形が明確であり、数が多い場合には Rubocop の自動修正機能が便利です。

先ほどみた通り、今回は 35 ヶ所の指摘のうち 35 ヶ所、つまりすべてが自動修正可能でしたので Rubocop の自動修正機能を使ってまとめて修正します。

なお、この時に意図しない変更を防ぐためにも事前にコミットしておくことをおすすめします。
.rubocop.yml を作成した時点でコミットしていたのはこのためです。

ただし、よく分からないまま自動修正を適当に使ってしまうと、予期せぬバグを生むことに繋がります。
Rubocop での指摘は数が多い&英語で読むのは大変ですが、必ず全ての指摘事項は一つ一つ確認するようにしましょう!

今回は自動修正で問題ない範囲ですので、自動修正していきます。

自動修正機能を使うには -a オプションをつければ OK です。
オプション付き Rubocop を実行します。

$ bundle exec rubocop -a
...
13 files inspected, 35 offenses detected, 35 offenses corrected

35 offenses correctedと表示されており、すべての指摘が自動で修正されたことが分かります。

もう一度 Rubocop で検査(自動修正なし)を実行すると、指摘事項がなくなっているはずです。

$ bundle exec rubocop
Inspecting 13 files
.............

13 files inspected, no offenses detected

これでコードの品質を担保することができました。

さて、ここまでの差分が Rubocop による指摘に対する修正だとわかるようにコミットしつつ、リモートリポジトリと同期しておきます。

$ git add .
$ git commit -m "Rubocop による指摘事項修正(Lint)"
$ git push

これで Rubocop の導入は完了です。

コミットの前に必ず Rubocop での検査を行い、コード品質を最善に保つように心がけましょう。

宿題

Rubocop の概要

今回は Rubocop の導入に重きを置いていたのですが、Rubocop そのものの概要については別途、落ち着いて理解を進めておきましょう。

Rubocop の指摘(違反)について

今回は同じ違反レベルの指摘ばかりでしたが、Rubocop では指摘内容に応じて違反レベルに段階がつけられています。
特にセキュリティに関する違反であればすぐに修正すべき、といった指摘になります。

他にも指摘内容の読み方・調べ方についても把握しておくと違反時の対応に困らなくて済むので、以下の参考サイトを一度読んでおきましょう。

無駄なファイルは作らない:Rails の自動生成ファイル設定

Rails では rails generate xxx コマンドを使ってコントローラ、モデルなどの動作に必要なファイルを自動で生成できます。

しかし、CSS や JavaScript ファイル、ヘルパーファイルなどは使わない場合もあるので、自動で生成されてしまうと消すのが面倒です。

そこで、一部ファイルについては rails generate コマンドを実行しても自動で生成されないように設定していきます。

config.generators 設定

設定するファイルは、既存の /configディレクトリにある application.rbというファイルです。

変更する前は以下のような内容が書かれています。

config/application.rb

require_relative "boot"

require "rails/all"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module Saigen
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 7.0

    # Configuration for the application, engines, and railties goes here.
    #
    # These settings can be overridden in specific environments using the files
    # in config/environments, which are processed later.
    #
    # config.time_zone = "Central Time (US & Canada)"
    # config.eager_load_paths << Rails.root.join("extras")
  end
end

まずは、見通しをよくするために一旦コメントアウトの部分は削除してしまいます。

config/application.rb (コメントアウト部分削除後)

require_relative "boot"

require "rails/all"

Bundler.require(*Rails.groups)

module TechLogApp
  class Application < Rails::Application
    config.load_defaults 7.0
  end
end

その後、class Application の中に以下の 4 行を追記します。

  class Application < Rails::Application
    config.load_defaults 7.0

    config.generators do |g| # ここから追記
      g.assets false          # CSS, JavaScriptファイルを自動生成しない
      g.helper     false      # helperファイルを自動生成しない
    end  # ここまで追記
  end

以上の 4 行で設定は完了です。

ここまでの変更をコミット、およびプッシュしておきます。

$ git add .
$ git commit -m "アセットファイル等はrails generate時に自動生成されないように設定"

テストライブラリの準備:Rspec 導入

ではここから、テストを書くための準備を進めていきましょう。

今回は Rails でテストを書くための準備をしましょう。
そのために本カリキュラムではRSpecという、テストフレームワークの gem を使用します。

RSpec とは?

RSpec は、Ruby や Rails の代表的なテストフレームワークの一つです。

実は RSpec を使わなくても Minitest というライブラリを Ruby では標準で使えるのですが、
RSpec は Minitest と比べ便利な機能が豊富に備わっており、直感的にテストを読み書きできることから非常に人気なフレームワークです。

(RSpec の使い方や基本構文については後述の宿題にて))

RSpec インストール

RSpec も gem であるため、Gemfile に追記して bundle installコマンドでインストールできます。

Rails で使うための RSpec gem は

Gemfile のうち、development と test 環境用のグループに追記しましょう。

group :development, :test do
  ...
  gem 'rspec-rails' # 追加
end
Fetching rspec-rails 5.1.2
Installing rspec-rails 5.1.2
Bundle complete!

Gemfile への追記が完了したら、bundle installコマンドで gem をインストールします。

$ bundle install
...
Fetching rspec-rails 5.1.2
Installing rspec-rails 5.1.2
Bundle complete! ...

bundle install が正常に完了したら、RSpec を動かすために必要なファイル群を rails g で作成します。

$ bundle exec rails g rspec:install
      create  .rspec
      create  spec
      create  spec/spec_helper.rb
      create  spec/rails_helper.rb

これで rspec コマンドから RSpec を即時実行できるようになります。
試しにコマンドを叩いてみましょう。

$ bundle exec rspec
No examples found.

Finished in 0.00056 seconds (files took 0.07792 seconds to load)
0 examples, 0 failures

現在はテストを作成してないので 0 件のテスト (0 examples) を実行した、という表示ではありますが、とにかく RSpec を使用する準備はできました。

次は便利に使うための初期設定を進めていきましょう。

初期設定

RSpec を使うにあたり、いくつか便利な初期設定や、追加で入れておくと開発しやすくなる gem を導入していきます。
順番にやっていきましょう。

不要なディレクトリ /test 削除

Rails プロジェクトを rails new で作成した際、デフォルトで作成される /testディレクトリは Minitest 用であるため、既に不要になりました。

もう使わないので、この時点で削除しておきましょう。

$ rm -r test/

FactoryBot インストール

FactoryBot とは、Rspec 標準で使える fixture に代わりにテストデータの生成を助けてくれるライブラリです。

FactoryBot を使えば、例えば以下のように 1 行で User モデルのテストデータを準備することができるため、テストを効率的に書くことができます。

user = FactoryBot.create(:user)

(詳しい使い方については後述の宿題にて)

FactoryBot を Rails で使うには、factory_bot_rails という gem をインストールしてあげます。

Gemfile のうち、development と test 環境用のグループに追記しましょう。

group :development, :test do
  ...
  gem 'factory_bot_rails' # 追加
end

Gemfile に追記できたら、いつも通り bundle install を実行します。

$ bundle install
Installing factory_bot_rails 6.2.0
Bundle complete! ...

これで FactoryBot のインストールができました。
これからモデルを作成する時に合わせて FactoryBot 用のファイルを生成していく時に説明しますが、rails g factory_bot:model モデル名というコマンドで FactoryBot 用のファイルを自動生成できます。

さて、追加でもう少し FactoryBot を便利に使う設定をしておきましょう。

RSpec のテストコードで FactoryBot のメソッドを使う際、そのモジュール名 (FactoryBot)を省略できるようにしておくと便利です。
省略するためには RSpec の基本設定をした時に自動生成された spec/rails_helper.rb ファイルに追記します。

spec/rails_helper.rb

RSpec.configure do |config|
  ...
  config.include FactoryBot::Syntax::Methods # 最下段に追記
end

この記述により、FactoryBot というクラス名をつけなくても FactoryBot のメソッドを spec ファイル内で呼び出せるようになります。

例)

user = create(:user)

rails generator 生成ファイル

rails g (rails generater)コマンドでコントローラやモデルを作成する際、
RSpec テスト用のファイルも自動で作成されるのですが、いくつかのファイルについては本カリキュラムにおいては不要なものです。

そのため、Rails の初期設定をした時と同じく config/application.rbに追記します。
前回追記した config.generators do |g|ブロックの中に追記してください。

config/application.rb

    config.generators do |g|
      g.assets false
      g.helper     false
      g.test_framework :rspec, # ここから5行を追記
        fixtures: false, # テストDBにレコードを作るfixtureの作成をスキップ(FactoryBotを使用するため)
        view_specs: false, # ビューファイル用のスペックを作成しない
        helper_specs: false, # ヘルパーファイル用のスペックを作成しない
        routing_specs: false # routes.rb用のスペックファイル作成しない
    end

各行の意味については、コメントで説明をつけてあります。

1 点、ビュー (view) ファイルについて補足しますと、view で表示する内容については他の (画面内を操作するような) システムテストで検査をするため、ユニットテスト(view 単体のテスト)を書かないことが一般的です。
そのため、本カリキュラムにおいても view のテストは自動で作成されないように設定しました。

テスト結果の出力調整

デフォルトの形式 → ドキュメント形式へ変更
必須ではないですが、RSpec の出力結果を見やすくします。

Rails プロジェクト(./workspace/techlog-app)の直下にある .rspec ファイルの最下段に、以下の 1 行を追加します。

.rspec

--format documentation # 出力結果をドキュメント風に見やすくする

まだテストを実際に書いて、出力結果を見ていないため恩恵は受けていませんが、今後出力されるテスト結果が少し見やすくなります。

テスト起動の高速化

ソースコードを修正した後、テストの実行は少しでも早い方がいいですよね。
そんな時、Spring という仕組みを使うと少しですがテストの実行を早くできます。

これも gem を使って導入できるので、サクッとやっておきましょう。

Gemfile の development グループに gem 'spring-commands-rspec'を追記して

Gemfile

group :development do
  ...
  gem 'spring-commands-rspec' # 追記
end

Gemfile に書けたら、いつも通り bundle install を実行します。

$ bundle install
Installing spring 4.0.0
Fetching spring-commands-rspec 1.0.4
Installing spring-commands-rspec 1.0.4
Bundle complete!

その後、高速起動用のファイルである bin/rspecを作成するのですが、コマンド一発で作成することができます。

$ bundle exec spring binstub rspec
* bin/rspec: generated with Spring

実行すると、bin/ ディレクトリ内に rspec という実行用ファイルが作成されているはずです。

$ ls bin/
bundle          importmap       rake            setup
dev             rails           rspec           spring

最後に、テスト環境では Spring がキャッシュをしないよう、アプリケーション側で reload を有効にしてあげます。
config/environments/test.rbファイルを開き、config.cache_classesと書かれた 1 行を編集します。

config/environments/test.rb

config.cache_classes = false # trueからfalseに変更

これで Spring を使って RSpec を高速で起動する準備ができました。
実際に使ってみましょう。

Spring の恩恵を受けるためには bundle exec rspecではなく bin/rspecコマンドでテストを実行してあげます。

$ bin/rspec
DEBUGGER: Attaching after process 37366 fork to child process 37415
Running via Spring preloader in process 37415
No examples found.

Finished in 0.00047 seconds (files took 0.10171 seconds to load)
0 examples, 0 failures

まだテストが 0 件であるため bundle exec rspec コマンドで普通に実行した時との違いはほとんど分かりませんが、テストが増えてきたら比べてみるといいでしょう。

ここまでで一旦、初期設定をできたのでコミットしておきます。

$ git add .
$ git commit -m "RSpecの初期設定を完了"

Rubocopチェック

色々と設定ファイルの自動生成や追記を行ったので、 Rubocop でのチェックを行なっておきましょう。

$ bundle exec rubocop
Inspecting 12 files
C.........CC

Offenses:

Gemfile:58:3: C: [Correctable] Bundler/OrderedGems: Gems should be sorted in an alphabetical order within their section of the Gemfile. Gem factory_bot_rails should appear before rspec-rails.
  gem 'factory_bot_rails' # 追加
  ^^^^^^^^^^^^^^^^^^^^^^^
spec/rails_helper.rb:6:7: C: [Correctable] Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.
abort("The Rails environment is running in production mode!") if Rails.env.production?
      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
spec/spec_helper.rb:49:1: C: [Correctable] Style/BlockComments: Do not use block comments.
=begin ...
^^^^^^

3 つの指摘が出ました。
今回は丁寧に一つずつ見ておきます。

・Bundler/OrderedGems: Gems should be sorted in an alphabetical order…
Gemfile に gem 名を書く時はアルファベット順に書こうね、という指摘です。
直さなくても特に支障はないものの、現時点での順番に意味があるわけではないので直しておきましょう。
Correctableと書いてあるので、-aオプションを付ければ自動修正可能な指摘です。・Style/StringLiterals: Prefer single-quoted strings…
RSpec 設定ファイル作成時、自動で生成されたファイルで “”を使った文字囲いがありました。
これもただ ” に直すだけなので、自動修正で OK です。・Style/BlockComments: Do not use block comments.
これも自動生成されたファイルに関する指摘です。
=begin という書き方がコメントアウトをしていますが、パッと見でコメントであると分かりにくいため指摘が入っています。
コメントなので、直しても動作への影響はないため自動修正してしまいましょう。

以上 3 点、すべて自動修正で問題ないことを確認できたので -a オプションをつけて自動修正します。

$ bundle exec rubocop -a
Inspecting 12 files
C.........CC

Offenses:

Gemfile:58:3: C: [Corrected] Bundler/OrderedGems: Gems should be sorted in an alphabetical order within their section of the Gemfile. Gem factory_bot_rails should appear before rspec-rails.
  gem 'factory_bot_rails' # 追加
  ^^^^^^^^^^^^^^^^^^^^^^^
spec/rails_helper.rb:6:7: C: [Corrected] Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.
abort("The Rails environment is running in production mode!") if Rails.env.production?
      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
spec/spec_helper.rb:49:1: C: [Corrected] Style/BlockComments: Do not use block comments.
=begin ...
^^^^^^
spec/spec_helper.rb:91:1: C: [Corrected] Layout/CommentIndentation: Incorrect indentation detected (column 0 instead of 2).
#   Kernel.srand config.seed
^^^^^^^^^^^^^^^^^^^^^^^^^^^^

12 files inspected, 4 offenses detected, 4 offenses corrected

3 点については既に見ていましたが、4 つ目の指摘事項として「コメントのインデント」に対する指摘が追加され、そのまま自動修正されています。

Layout/CommentIndentation: Incorrect indentation detected (column 0 instead of 2)

※詳しい説明はドキュメントをご参照ください。

これは、指摘事項の修正を行なったことで追加の指摘事項が浮上したためです。
このように、自動修正は予期せぬところまで修正をされてしまう可能性があるため、事前にコミットをしておくことをおすすめします。
(現場の方針によっては、Rubocop での検査を終えてからコミットすべしという場合もあります。チームルールを確認してください。)

さて、Rubocop でのチェックと修正を終えたため、変更内容をコミットしておきます。

$ git add .
$ git commit -m "Rubocop による指摘事項修正(Lint)"
$ git push

宿題

今回は盛りだくさんの内容でした。
本カリキュラムだけでなく、実際の現場では当たり前のようにテストを書くので、宿題記事をしっかりと読んでテストの書き方を今のうちに覚えておきましょう!

RSpec の基本構文や便利機能

RSpec でのテストの書き方自体は使い回しをしやすいため、一度書いてしまえば色々な画面で流用できるのですが
便利な機能が豊富な分、構文やルールが多いために初心者は取っ付きにくいものではあります。
(RSpec の書き方だけで本が一冊出るほどです。)

本カリキュラムだけでは RSpec の構文のうち主要なものしか使わないですが、
どういった構文や機能があるのかを知っておくことはテストを書く上で重要ですので、
本カリキュラムでテストを書く前に、以下 記事を予め読んでおくことを強くおすすめします。

実際に現場で開発する際にも、よくこちらを参照するぐらい有用な記事です。

ボリュームが多く、初心者の方はくじけそうになるかもしれませんが、現場で開発するには必須級のスキルですのでこのタイミングで頑張りましょう!

FactoryBot の使い方

FactoryBot を用いたテストデータの用意本カリキュラムでも度々使うため、基本的な使い方は事前に知っておくとスムーズに導入できます。
User モデルを例にした使い方がまとめられているので、軽くでも読んでおきましょう。

テストを書いてみよう:トップページ作成

ここまででテストやファイル生成といった、本格的にソースコードを変更するための準備を進めてきました。

今回は一旦仮のトップページを作成し、主にテストの基本的な書き方をみていきましょう。

Home コントローラー作成

まずは rails g controller を使って Home コントローラー、および top アクションを作成してあげます。
rails g controller コントローラー名 アクション名という形式でコマンドを実行します。

$ bundle exec rails g controller Home top
      create  app/controllers/home_controller.rb
       route  get 'home/top'
      invoke  tailwindcss
      create    app/views/home
      create    app/views/home/top.html.erb
      invoke  rspec
      create    spec/requests/home_spec.rb

以前、生成ファイルの設定を行なっていたおかげでアセットファイル、ヘルパーファイルは生成されていませんね。
view のテストファイルも生成されていないことが分かります。

また、invoke tailwindcssというように Tailwind CSS に関する変更が行われていることに注意しておきましょう。
詳細についてはこの後のカリキュラムで解説していきます。

初期動作確認

Home コントローラーを作成した段階で、一旦トップ画面にアクセスしてみましょう。

rails s コマンドで開発用サーバを起動します。

$ bundle exec rails s
=> Booting Puma
=> Rails 7.0.3 application starting in development
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 5.6.4 (ruby 3.0.4-p208) ("Birdie's Version")
*  Min threads: 5
*  Max threads: 5
*  Environment: development
*          PID: 40714
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
Use Ctrl-C to stop

問題なく起動することを確認したら、ブラウザで http://127.0.0.1:3000/home/topにアクセスしましょう。

このように、Home#top の view が表示されていれば OK です。
(この時、既に Tailwind CSS を使った見た目になっていますが、それについては後ほど)

ひとまず動作確認は済んだので、ここまでで一旦コミットしておきます。

$ git add .
$ git commit -m "Homeコントローラとtopアクションを作成"

また、Rubocop の検査もかけておきましょう。

$ bundle exec rubocop
Inspecting 14 files
............C.

Offenses:

...
spec/requests/home_spec.rb:10:1: C: [Correctable] Layout/EmptyLinesAroundBlockBody: Extra empty line detected at block body end.

14 files inspected, 5 offenses detected, 5 offenses autocorrectable

詳細は割愛しますが、すべてスタイルやレイアウトに関する内容であり、動作には影響ないことを確認できたので自動修正します。

$ bundle exec rubocop -a

自動修正が完了したら、この段階で一旦コミットしておきましょう。

$ git add .
$ git commit -m "Rubocop による指摘事項修正(Lint)"

ルーティングの変更

現在は ‘/home/top’ というパスで Home#top の view を表示することになりますが、
一旦、このページをトップページとするためにルーティングを変更しましょう。

config/routes.rb を以下のように修正します。

config/routes.rb

Rails.application.routes.draw do
  root 'home#top'
end

これで / というパスが Home#top 画面を表示するためのパスになります。

ブラウザで今度は http://127.0.0.1:3000にアクセスし、Home#top が表示されることを確認してください。

image

Home#top のテスト作成

ここからは Home コントローラの top アクションに関するテストを準備します。

まだモデルは用意されていないため、基本的な動作のテストのみとはなりますが、簡単なところから初めて少しずつ慣れていきましょう。

テストの変更(リファクタ)

Home#top 作成時、自動生成されたテストファイルである spec/requests/home_spec.rbというファイルが生成されていたはずです。
このファイルを、以下のように修正してください。

spec/requests/home_spec.rb

require 'rails_helper'

RSpec.describe 'Home', type: :request do
  describe 'GET /' do
    it 'HTTP ステータス 200 を返す' do
      get '/'
      expect(response).to have_http_status(200)
    end
  end
end

習生後テストのそれぞれの行の意味について、以下の通りコメントで説明をつけましたので確認してください。

require 'rails_helper' # rails_helper で定義した設定を読み込み

RSpec.describe 'Home', type: :request do # テスト対象モジュールとテストの種類
  describe 'GET /' do # テストする機能に関する説明
    it 'HTTP ステータス 200 を返す' do # どういう結果を期待しているか
      get '/' # 確認したい操作: / というパスに GET でリクエストを投げる
      expect(response).to have_http_status(200) # 期待する確認結果
    end
  end
end

(describe や it については後述の宿題欄にて説明)

request のテストなので、ページの中身よりもとにかくアクセス時(リクエストをサーバに投げた時)の挙動をテストしています。

テスト実行

さて、これで一つテストが追加できたので RSpec を走らせてみましょう。

$ bin/rspec
DEBUGGER[spring app | techlog-app | started 33 mins ago | test mode#42184]: Attaching after process 40675 fork to child process 42184
Running via Spring preloader in process 42184

Home
  GET /
    HTTP ステータス 200 を返す

Finished in 0.12033 seconds (files took 0.33231 seconds to load)
1 example, 0 failures

上記のように、テストの結果画面では describe として書いた「テストする機能に関する説明」と、it として書いた「期待する結果」が出力されていますね。
このように、どのテストがどうなったか?は (–format documentation オプションのおかげで) 整形されて出力されるので、コケているテストがあれば詳細を確認しましょう。

ひとまず、Home#top のテストを作成できたのでコミットしておきます。
今回はテストの中身をリファクタリング(より良い形に変更)したので、その旨をコミットメッセージに含めておきます。

$ git add .
$ git commit -m "Home#top のテストをリファクタ"

宿題

describe/context/it の使い分け

RSpec のカリキュラムでの参考サイトを読んだ方は理解済みかもしれませんが、
RSpec のテストでは describe/context/it それぞれのグループを適切に使い分けることで、どのようなテストを書いてあるかがひと目で分かるようになります。

まだ読んでいない方や、一度読んだけどボリュームが多いために忘れてしまった方は、参考サイトの以下の項目だけでも読んでおきましょう。

RSpec マッチャ(matcher)

RSpec を使うメリットの一つとして、今回出てきた have_http_statusのような マッチャ という機能があります。
マッチャを使わなければ response.statusという形でレスポンスから HTTP ステータスコードを取り出さないといけないのですが、マッチャを使ったことでより完結に書くことができました。

このように、RSpec には便利なマッチャがたくさん用意されています。

もちろん、すべてを把握しておく必要はないのですが、使用頻度の高いものだけでも覚えておくとテストを高速に書くことができるようになります。

HTTP ステータス

テストの中で、特定の URL にアクセスした時のレスポンスに含まれる HTTP ステータスコード を照合していました。
HTTP ステータスコードは Web アプリを開発する上では必須知識の一つですので、馴染みがない方はこの機会に概要だけでも掴んでおきましょう。

より高品質なアプリへ: 統合テスト (System Spec) の導入

この節では、前回作成したトップページ (Home#top) の統合テストを作成します。

Capybara について

統合テストはユニットテストと異なり、アプリケーション全体システムとして期待通りに動くことを確かめるためのテストです。
(RSpec においては統合テストのことを System Spec という呼び方をすることがあります。)

統合テストでは Capybara という gem を使用します。
Capybara は Rails 5.0 以降であれば最初からインストールされており、実際、Gemfile を見ると最初から書かれているはずです。

Gemfile

group :test do
  # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
  gem 'capybara'

Capybara を使うと例えば「フォームに文字を入力し、送信ボタンを押す」というような、実際に画面を操作する流れに沿ったテストを直感的に書くことができます。

次は、Capybara に備えられているメソッドのうち、よく使うものを紹介します。

Capybaraの基本メソッド(操作)

本カリキュラムでも一部は登場しますので、使いながら覚えていきましょう。

visit

  • 書き方:visit '遷移したいページのパス'
  • 実行される操作:指定したページに遷移

click_link

  • 書き方:click_link ‘リンク文字列’
  • 実行される操作:指定したリンク文字列を持つ a タグ(リンク)をクリック

fill_in

  • 書き方:`fill_in ‘ラベル文字列’, with: ‘入力したい文字列’
  • 実行される操作:「入力したい文字列」を指定のフォームに入力

click_button

  • 書き方:click_button 'ラベル文字列'
  • 実行される操作:指定したラベルを持つボタンをクリック

Capybaraの基本メソッド(結果確認)

page

visit でアクセスした時、表示されるページの内容を取得します。

have_content('xxx')という構文で、ページ内に特定の文字列が含まれているかどうかを確認するといった使い方があります。

current_path

現在のパスを取得します。

例えば、ログインしていないユーザーは別のページへリダイレクトするような動作を検証したい時、正常にリダイレクト先のページへ遷移したかどうかを確認する時に使います。

Home#top の統合テスト

それでは実際に Home#top、つまりトップページを表示する機能の統合テストを作成してみましょう。
とはいってもまだフォームもボタンも何も用意していないので、アクセスした時に期待した文字列 (view ファイルに書かれている文字列)が表示されることの確認だけします。

System Spec ファイル作成

統合テスト(System Spec)ファイルはrails g rspec:system ファイル名という形式のコマンドで雛形を作成することができますので、実行してみましょう。

$ bundle exec rails g rspec:system home
      create  spec/system/homes_spec.rb

動作に支障はないのですが名前が homes_になっているので、統一のためにhome_にリネームしておきます。

$ mv spec/system/homes_spec.rb spec/system/home_spec.rb

では、生成されたファイルを spec/system/home_spec.rbの中身を確認してみましょう。

spec/system/home_spec.rb

require 'rails_helper'

RSpec.describe "Homes", type: :system do
  before do
    driven_by(:rack_test)
  end

  pending "add some scenarios (or delete) #{__FILE__}"
end

この段階でダブルクォーテーション "" が含まれていたので、Rubocop の自動修正で直してしまいます。

$ bundle exec rubocop -a

さて、雛形の中身ですが、現在は pending となっているのでその部分のテストは実行されません。
試しにこのファイルだけテストを走らせてみます。

$ bin/rspec spec/system/home_spec.rb
...
Pending: (Failures listed here are expected and do not affect your suite's status)

  1) Homes add some scenarios (or delete) .../spec/system/home_spec.rb
     # Not yet implemented
     # ./spec/system/home_spec.rb:8

Finished in 0.00121 seconds (files took 0.36953 seconds to load)
1 example, 0 failures, 1 pending

このように System Spec で pendingと書いておくと、そのテストはどんな内容を書いていても実行されません。

では次に、System Spec ファイルを修正していきます。

System Spec ファイル修正

Home#top の最低限のテストということで、以下のような最低限の動作だけテストします。

  1. / (トップページ) にアクセスする
  2. ブラウザでアクセスした時と同じく Home#topという文字列が表示される

上記程度のテストであれば数行で済むので簡単です。
spec/system/home_spec.rbを次のように書き換えてください。

spec/system/home_spec.rb

require 'rails_helper'

RSpec.describe 'Home', type: :system do
  before do
    driven_by(:rack_test)
  end

  describe 'トップページの検証' do
    it 'Home#top という文字列が表示される' do
      visit '/'

      expect(page).to have_content('Home#top')
    end
  end
end

テスト実行

それでは System Spec を実行してみましょう。
編集したテストのみを実行するには、いつもの bin/rspecに続いてファイル名を渡してあげます。

$ bin/rspec spec/system/home_spec.rb
...
Home
  トップページの検証
DEBUGGER[rspec#44711]: Attaching after process 44690 fork to child process 44711
    Home#top という文字列が表示される

Finished in 6.03 seconds (files took 0.33855 seconds to load)
1 example, 0 failures

0 failuresという表示、つまり失敗したテストが 0 件となっていることを確認してください。

今回はアクセス&表示内容の確認だけのテストでしたが、カリキュラムの後半ではフォームへの入力や、レコードが増えることの確認も行なっていきます。

ここまでの変更を一旦コミットしておきます。

$ git add .
$ git commit -m "トップページの統合テストを追加"

Headless モードへ変更

System Spec を実行した際、ブラウザが一時的に立ち上がって画面が表示されたことにお気付きでしょうか?
Capybara は実際にブラウザを使って画面の確認を行うため、そのような挙動になります。

しかし、毎回テストを走らせる度にブラウザが立ち上がるのは少々うるさく、ブラウザを立ち上げて描画する分、テストが遅くなります。

そこで Capybara を Headless モード で実行し、ブラウザを起動せずとも System Spec が走るようにしましょう。

Headless モードで実行するための設定は簡単でして、System Spec のテスト内容の前に Headless モードでの実行を指定してあげるだけです。

以下の 3 行を追記します。

spec/system/home_spec.rb

...
RSpec.describe 'Home', type: :system do
  before do # ここから追記
    driven_by :selenium_chrome_headless
  end # ここまで追記

この状態で再度 System Spec を走らせます。

$ bin/rspec spec/system/home_spec.rb
...
Home
  /home/top 画面の検証
DEBUGGER[rspec#47924]: Attaching after process 47918 fork to child process 47924
    Home#top という文字列が表示される

Finished in 1.48 seconds (files took 0.34879 seconds to load)
1 example, 0 failures

今度はブラウザが立ち上がることなくテストが完了することを確認してください。

また、先ほどまでは 5~6 秒ほどテストにかかっていましたが、今度は 1~2 秒程度で終わるようになっています。

統合テストは一つひとつの実行に時間がかかるため、今後増えてきた時のためにも高速化の工夫が重要になってきますので、覚えておきましょう。

ここまでの変更をコミットし、リモートリポジトリに同期して今回は終わりです。

$ git add .
$ git commit -m "HomeのSystem SpecをHeadlessモードで実行するように変更"
$ git push

宿題

Capybara の便利なメソッド集

本カリキュラムで使用&紹介する Capybara のメソッドはごく一部ですが、紹介しないメソッドでも使い所があり、有用なものが多くあります。

他にはどのようなメソッドがあるかや、便利なテクニックについての全体像だけでも知っておくと後で役立ちますので、こちらのサイトを目次だけでも一度は読んでおきましょう。

ユーザー認証機能 (Devise)

今回から、近年の Web アプリケーションではほぼ備わっているユーザー認証機能を作成します。
TechLog では誰でも学習ログを投稿できるわけではなく、事前に新規登録したユーザーだけが投稿できるようにしていきます。

devise とは

ここでは便利なログイン機能を手軽に実装できる devise という gem を使用します。

もちろん、ユーザー認証機能をゼロから作成しても良いのですが、基本的な新規登録・ログイン・ログアウト機能ですら穴なく作ることは難しいものです。

そこで devise を使うと、簡単に新規登録・ログイン・ログアウトといった基礎的な機能を実装できるのはもちろん、
ログインしているユーザー ID を簡単に view で使えるなど、ユーザー認証機能に付随して使いたい機能が多く備わっています。

他にもパスワード再発行機能など、使える機能は多いのですが TechLog では基本的な機能の実装のみに留めておきます。

devise をインストール

Gemfile を開き、以下の 1 行を追加します。
(devise は本番環境でも使用するため、group は development でも test でも無いところに記載してください。)

Gemfile

gem 'devise' # 追記

Gemfile に追記が完了したら、いつも通り bundle install を実行します。

$ bundle install
Fetching devise 4.8.1
Installing devise 4.8.1
Bundle complete! 24 Gemfile dependencies, 102 gems now installed.
Bundled gems are installed into `./.bundle`

初期設定

devise の初期設定に必要なファイルは、コマンドで作成できます。
devise をインストールすると rails g devise:xxx というコマンドがいくつか使えるようになるのですが、
その中の一つで rails g devise:install コマンドを最初は使います。

$ bundle exec rails g devise:install
      create  config/initializers/devise.rb
      create  config/locales/devise.en.yml
===============================================================================

Depending on your application's configuration some manual setup may be required:

コマンドを実行することで、config ディレクトリ配下にファイルが生成されました。

  • config/initializers/devise.rb
  • config/locales/devise.en.yml

一つ目の devise.rb は、devise に関する様々な設定を記述するファイルとだけ今はご認識ください。

もう一つの devise.en.yml は、devise を使った操作で画面にメッセージを表示する際、どのような内容を表示するかを決めているファイルです。
ファイル名の .en は英語 (English) であることを示しており、その名の通り英語でのメッセージ専用のファイルとなっています。
しかし TechLog は日本語の Web アプリですので、後のカリキュラムで日本語用のファイルも作成していきます。

User モデル作成

次に、ユーザーを表す User モデルを作成します。

通常、モデルを作成するには rails g model Xxx コマンドですが、
devise を活用した User モデルを作成するには rails g devise User とする必要があるのでご注意ください。

$ bundle exec rails g devise User
      invoke  active_record
      create    db/migrate/20220716053859_devise_create_users.rb
      create    app/models/user.rb
      invoke    rspec
      create      spec/models/user_spec.rb
      invoke      factory_bot
      create        spec/factories/users.rb
      insert    app/models/user.rb
       route  devise_for :users

新しく生成されたファイルと、自動的に編集されたファイルについて軽くおさらいしておきましょう。

  • db/migrate/20220716053859_devise_create_users.rb
    • users テーブルを作成するためのマイグレーションファイル
  • app/models/user.rb
    • User モデルを扱うためのファイル
  • spec/models/user_spec.rb
    • User モデルの RSpec (テスト) ファイル
  • spec/factories/users.rb
    • テスト内で一時的に User を作成するための FactoryBot 用ファイル

また、ルーティングも自動的に作られていることに注意してください。

config/routes.rb

Rails.application.routes.draw do
  devise_for :users # この1行が追加されている
  root 'home#top'
end

見慣れない形ではありますが devise ではこの 1 行で基本的なルーティング設定は完結するよう、
裏側で複雑な設定が行われています。

例えば /users/sign_up というパスにアクセスすることで、ユーザーの新規登録画面を開くことができたりします。

https://qiita.com/beanzou/items/1ff9c7cba61fd1fa5c80

マイグレーション実行

前項でマイグレーションファイルが自動的に生成されましたので、マイグレーションを実行しておきます。

$ bundle exec rails db:migrate
== 20220716053859 DeviseCreateUsers: migrating ================================
-- create_table(:users)
   -> 0.0009s
-- add_index(:users, :email, {:unique=>true})
   -> 0.0003s
-- add_index(:users, :reset_password_token, {:unique=>true})
   -> 0.0003s
== 20220716053859 DeviseCreateUsers: migrated (0.0015s) =======================

test 環境についても同じくマイグレーションしておきます。

$ RAILS_ENV=test bundle exec rails db:migrate
...

nickname カラムを追加

TechLog では、誰が投稿した学習ログであるかを表すために ニックネーム (nickname) を使用します。
しかし、現在の users テーブルでは nickname カラムを追加していないため、追加するためのマイグレーションファイルを生成します。

$ bundle exec rails g migration AddNicknameToUser nickname:string
      invoke  active_record
      create    db/migrate/20220716061809_add_nickname_to_user.rb

生成されたマイグレーションファイルを開き、以下のように修正します。

db/migrate/2022xxxxxxxx_add_nickname_to_user.rb

class AddNicknameToUser < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :nickname, :string
  end
end

では development と test それぞれの環境のデータベースでマイグレーションを実行しておきます。

development

$ bundle exec rails db:migrate
== 20220716061809 AddNicknameToUser: migrating ====================================
-- add_column(:users, :nickname, :string)
   -> 0.0009s
== 20220716061809 AddNicknameToUser: migrated (0.0009s) ===========================

test

$ RAILS_ENV=test bundle exec rails db:migrate
== 20220716061809 AddNicknameToUser: migrating ====================================
-- add_column(:users, :nickname, :string)
   -> 0.0005s
== 20220716061809 AddNicknameToUser: migrated (0.0005s) ===========================

さて、実は devise では新規登録やログイン時に受け付けるパラメータを制限する仕組みが備わっています。

そのため、マイグレーションで nickname カラムを増やしていたとしても
新規登録時の入力フォームで渡されたパラメータ内の nickname は弾かれてしまいます。

そのため、ApplicationController にてストロングパラメータを設定しましょう。

公式の READMEの通りに今回は設定します。

app/controllers/application_controller.rb を以下のように書き換えてください。

app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_up, keys: [:nickname])
  end
end

devise_parameter_sanitizer.permit(:sign_up, keys: [:nickname])という 1 行では、
sign_up、つまり新規登録時に nickname というキーのパラメータも許可することを設定しています。

これで devise を使うための初期設定は完了しました。
ここまでの変更をコミットしておきましょう。

$ git add .
$ git commit -m "deviseインストールと初期設定完了"
$ git push

宿題

devise で出来ること

devise を使うと最低限の設定でユーザー認証機能を実装できる反面、
devise (gem) の中で何をやっているかが分からないため、ちょっとしたカスタマイズをしたい時に取っ付きにくいというデメリットがあります。

しかし、よく使われる便利な devise の便利な機能は限られているため、その全容を把握しておくだけでも今後の実装がだいぶ楽になります。

以下の記事を参考に、どのようなヘルパーメソッドがあるか?認証周りの裏側ではどのような処理がされているのか?を一度読んでおくといいでしょう。

devise のルーティング

今回のカリキュラムでは config/routes.rb に 1 行が自動で追加されていましたね。

  devise_for :users

この 1 行により users/sign_up というパスで新規登録画面を表示できたりと、内部的に様々なルーティングを実現できました。

TechLog で使うのは新規登録&ログインのみですが、それを含めどのようなルーティングが使えるのかは確認しておいてください。

User モデルのテスト

前回、devise を用いて User モデルを作成しました。

実際に User モデルを使用する前に、User モデルが機能することを確認するためのテストを作成しておきましょう。

また、User モデルの一部の属性についてはバリデーションを設定しつつ、そのテストも同時に作成します。

テスト関連ファイルの確認

前回 rails g devise User を実行した際、テストに関連する以下のファイルが作成されていました。

  • spec/factories/users.rb
  • spec/models/user_spec.rb

今回はこれらを修正し、最後に bin/rspec 実行時に全てのテストが通ることを目標とします。

Factory ファイルの修正

spec/factories/users.rb は、以前導入した FactoryBot を用い、テストで User を簡単に作成するためのファイルです。

こちらを以下のように修正してください。

spec/factories/users.rb

FactoryBot.define do
  factory :user do
    nickname { 'テスト太郎' }
    sequence :email do |n|
      "test#{n}@example.com"
    end
    password { '111111' }
    password_confirmation { '111111' }
  end
end

ユニークキーである email の一部だけが異なる、複数の User を作成する Factory となっています。

User 生成&取得のテスト

次に User モデルの Spec であるspec/models/user_spec.rbを修正します。

まずは基本的なところとして、FactoryBot で生成した User を User.first で取得し、
取得した User が想定通りの属性(nickname, email) を持っていることを確認するテストを用意しましょう。

spec/models/user_spec.rb

require 'rails_helper'

describe User do
  let(:nickname) { 'テスト太郎' }
  let(:email) { 'test@example.com' }

  describe '.first' do
    before do
      create(:user, nickname: nickname, email: email)
    end

    subject { described_class.first }

    it '事前に作成した通りのUserを返す' do
      expect(subject.nickname).to eq('テスト太郎')
      expect(subject.email).to eq('test@example.com')
    end
  end
end

これでテストを実行してみます。

テストを実行する際、比較的時間がかかる System Spec まで一緒に実行すると開発スピードが落ちるため、User モデルの Spec だけを実行します。
特定のテストだけを実行したい場合は、bin/rspec の後に半角スペースを挟み、さらに実行したいテストのファイル名を渡します。

1 つだけですが、テストが通っていることを確認してください。

$ bin/rspec spec/models/user_spec.rb
...
User
  .first
    事前に作成した通りのUserを返す

Finished in 0.06006 seconds (files took 0.5011 seconds to load)
1 example, 0 failures

User.nickname のバリデーション(文字数)

ここでは User の nickname 属性に文字数制限のバリデーションを設定していきます。
その際、バリデーションのテストを作成し、それから実際にバリデーションを設定します。

※このように要件を満たすためのテストを事前に作成してから実装をすることをテスト駆動開発といいます。

バリデーションのテストを追加

今回は、ニックネームは 20 文字までという制限をかけていきます。

そこで、先にこのバリデーションに関するテストを作成しておき、それから実際に User モデルにバリデーションを設定します。
先にテストを書いておくことで仕様が明確になり、確実にバリデーションを実装することができます。

テストでは以下の 2 つを追加します。

  • User.nickname が 20 文字の場合、User オブジェクトが有効であること
  • User.nickname が 21 文字の場合、User オブジェクトが無効であること

これを満たすようなテストとして、.first という describe とはまた別に .validation という describe (分け方) のテストを追加します。

user_spec.rb の全文は以下のようになります。

spec/models/user_spec.rb

require 'rails_helper'

describe User do
  let(:nickname) { 'テスト太郎' }
  let(:email) { 'test@example.com' }
  let(:password) { '12345678' }
  let(:user) { User.new(nickname: nickname, email: email, password: password, password_confirmation: password) }

  describe '.first' do
    before do
      create(:user, nickname: nickname, email: email)
    end

    subject { described_class.first }

    it '事前に作成した通りのUserを返す' do
      expect(subject.nickname).to eq('テスト太郎')
      expect(subject.email).to eq('test@example.com')
    end
  end

  describe 'validation' do

    describe 'nickname属性' do

      describe '文字数制限の検証' do
        context 'nicknameが20文字以下の場合' do
          let(:nickname) { 'あいうえおかきくけこさしすせそたちつてと' } # 20文字

          it 'User オブジェクトは有効である' do
            expect(user.valid?).to be(true)
          end
        end

        context 'nicknameが20文字を超える場合' do
          let(:nickname) { 'あいうえおかきくけこさしすせそたちつてとな' } # 21文字

          it 'User オブジェクトは無効である' do
            user.valid?

            expect(user.valid?).to be(false)
            expect(user.errors[:nickname]).to include('is too long (maximum is 20 characters)')
          end
        end
      end
    end
  end
end

user という変数に作成した User オブジェクトを格納しています。

有効な user については user.valid?true となることを確認しています。

一方、無効な user についてはuser.valid?実行時にuser.errros にエラー内容が入ることを利用したテストとなっています。

まだバリデーションを設定はしていないので、テストは失敗することを確認してください。

$ bin/rspec spec/models/user_spec.rb
...
Finished in 0.11684 seconds (files took 0.50221 seconds to load)
3 examples, 1 failure

Failed examples:

rspec ./spec/models/user_spec.rb:35 # User validation nickname文字数制限の検証 nicknameが20文字を超える場合 User オブジェクトは無効である

バリデーションを設定しても通るテストについてはそのままですが、21 文字以上の nickname というテストは失敗しています。

バリデーションを追加

では User モデルにバリデーションを追加しましょう。

app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  validates :nickname, length: { maximum: 20 } # 追加
end

nickname について、長さの最大が 20 文字であるという制約のバリデーションを追加しました。

テスト実行

バリデーションを追加したため、この時点でテストが通るはずです。

テストを再度実行し、User モデルのテストはすべて通ることを確認しましょう。

$ bin/rspec spec/models/user_spec.rb
...
User
  .first
    事前に作成した通りのUserを返す
  validation
    nickname属性
      文字数制限の検証
        nicknameが20文字以下の場合
          User オブジェクトは有効である
        nicknameが20文字を超える場合
          User オブジェクトは無効である

Finished in 0.11225 seconds (files took 0.4578 seconds to load)
3 examples, 0 failures

バリデーションを設定し、テストが通ることも確認できたので一旦コミットしておきます。

$ git add .
$ git commit -m "User.nickname の文字数に関するバリデーションを追加"

User.nickname のバリデーション(空欄ではないこと)

続いて、User.nickname は空欄ではないことのバリデーションを追加してあげます。

バリデーションのテストを追加

文字数のバリデーションと同じく、テストを先に追加しましょう。
nickname が空欄である User オブジェクトを作り、user.valid? をかけた時に空欄を許可しないことを示すエラーメッセージが含まれることを確認します。

spec/models/user_spec.rb

    describe 'nickname存在性の検証' do
      context 'nicknameが空欄の場合' do
        let(:nickname) { '' }

        it 'User オブジェクトは無効である' do
          expect(user.valid?).to be(false)
          expect(user.errors[:nickname]).to include("can't be blank")
        end
      end
    end

なお、上記テストの挿入位置ですが事前に追加していた describe '文字数制限の検証' do と同列となるように追記します。
これは、nickname 属性に関するいくつかの検証のうち「文字数制限」と「存在性」(空欄ではないこと) の検証が同列であることを明確にするためです。

こうしておくと、テストを実行した際にインデントが揃ったテストどうしは同列であることが分かるため、仕様やテストの意図が分かりやすくなります。

さて、このタイミングでは追加したテストだけが失敗することを確認しましょう。

$ bin/rspec spec/models/user_spec.rb
...
User
  .first
    事前に作成した通りのUserを返す
  validation
    nickname属性
      文字数制限の検証
        nicknameが20文字以下の場合
          User オブジェクトは有効である
        nicknameが20文字を超える場合
          User オブジェクトは無効である
      存在性の検証
        nicknameが空欄の場合
          User オブジェクトは無効である (FAILED - 1)
...
Failures:

  1) User validation nickname属性 存在性の検証 nicknameが空欄の場合 User オブジェクトは無効である
     Failure/Error: expect(user.valid?).to be(false)

       expected false
            got true
...

nickname が空欄であり、正しくない User オブジェクトについて user.valid? は本来 false を返すことをテストは期待しています。
しかし、そのバリデーションをかけていないため、まだ user.valid?true を返しています。
結果として、テストは現時点では失敗することなります。

テスト駆動開発では最初に「失敗するテスト」を書くことが重要です。
逆に、この時点でテストが通ってしまうようであれば、正しいテストを書けていませんので注意しましょう。

続けて、このテストが通るようにバリデーションを追加しましょう。

バリデーションを追加

テストが書けたので、nickname の空欄を強制するバリデーションを追加します。
presence: true を追加することで実現できます。

app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  validates :nickname, length: { maximum: 20 }
  validates :nickname, presence: true # 追加
end

なお、本当は nickname に関するバリデーションは 1 行で書けるのですが、
テスト駆動開発の流れに則り最後にリファクタリングを行うため、あえて改善の余地があるコードのままにしておきます。

テスト実行

バリデーションを追加したので、今度はテストが通ることを確認してください。

$ bin/rspec spec/models/user_spec.rb
DEBUGGER: Attaching after process 50812 fork to child process 50861
Running via Spring preloader in process 50861

User
  .first
    事前に作成した通りのUserを返す
  validation
    nickname属性
      文字数制限の検証
        nicknameが20文字以下の場合
          User オブジェクトは有効である
        nicknameが20文字を超える場合
          User オブジェクトは無効である
      存在性の検証
        nicknameが空欄の場合
          User オブジェクトは無効である

Finished in 0.08105 seconds (files took 0.4687 seconds to load)
4 examples, 0 failures

テストのリファクタリング

さて、テストが通りましたのでリファクタリング(以下、リファクタ)をしていきましょう。

リファクタ作業では、処理による最終的な結果を変えることなく重複を省いたり、設計を変えたりしてコードの改善を行います。

テスト駆動開発では

  1. 失敗するテストを書く
  2. 実装してテストを通す
  3. リファクタをしてコードを改善する

という流れで実装を進めていき、品質を高めていきます。

では実際にリファクタをしていきましょう。
User モデルのコードで nickname のバリデーションはわざわざ 2 行に分けていましたが、
先ほど軽く触れた通り 1 行でまとめられるのでリファクタしてしまいます。

app/models/user.rb

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  validates :nickname, presence: true, length: { maximum: 20 } # 変更
end

裏ファクタを終えたので最後にテストを再実行し、すべて通ることを確認します。

$ bin/rspec spec/models/user_spec.rb
...
4 examples, 0 failures

「テストを通している=動作の保証ができている」という流れで進めていたため、
テストさえ通るようにすれば、安心してリファクタリングできますね!

これで一通りのテスト駆動開発の流れは掴めたかと思います。

では最後にすべてのテストを走らせ、コケているテストが無いことを確認しておきましょう。
開発を進めていくうちに予期せぬ箇所に影響することもあるため、最終的にはすべてのテストを走らせておくことが重要です。

$ bin/rspec
...
Finished in 2.61 seconds (files took 0.34742 seconds to load)
6 examples, 0 failures

問題ないことを確認できたら、ここまでの変更をコミットし、プッシュもしておきます。

$ git add .
$ git commit -m "User.nicknameのバリデーションを追加"
$ git push

補足

テストコードの全文

今回はテストコードについて何度か修正を行ったため、全貌を掴むのに苦労したかと思います。
念のためテストの全文を載せておきますので、自分のテストと照らし合わせておきましょう。

spec/models/user_spec.rb

require 'rails_helper'

describe User do
  let(:nickname) { 'テスト太郎' }
  let(:email) { 'test@example.com' }
  let(:password) { '12345678' }
  let(:user) { User.new(nickname: nickname, email: email, password: password, password_confirmation: password) } # 変数に格納

  describe '.first' do
    before do
      create(:user, nickname: nickname, email: email)
    end

    subject { described_class.first }

    it '事前に作成した通りのUserを返す' do
      expect(subject.nickname).to eq('テスト太郎')
      expect(subject.email).to eq('test@example.com')
    end
  end

  describe 'validation' do

    describe 'nickname属性' do
      describe '文字数制限の検証' do
        context 'nicknameが20文字以下の場合' do
          let(:nickname) { 'あいうえおかきくけこさしすせそたちつてと' } # 20文字

          it 'User オブジェクトは有効である' do
            expect(user.valid?).to be(true)
          end
        end

        context 'nicknameが20文字を超える場合' do
          let(:nickname) { 'あいうえおかきくけこさしすせそたちつてとな' } # 21文字

          it 'User オブジェクトは無効である' do
            user.valid?

            expect(user.valid?).to be(false)
            expect(user.errors[:nickname]).to include('is too long (maximum is 20 characters)')
          end
        end
      end

      describe '存在性の検証' do
        context 'nicknameが空欄の場合' do
          let(:nickname) { '' }

          it 'User オブジェクトは無効である' do
            expect(user.valid?).to be(false)
            expect(user.errors[:nickname]).to include("can't be blank")
          end
        end
      end
    end
  end
end

email / password のバリデーションについて

nickname についてのみバリデーションを設定しましたが、email や password のバリデーションについても気になる方がいるかと思います。

しかし、devise で最初から用意されているカラムについては最低限のバリデーションが設定されているため、
存在性(空欄ではないこと)のような基本的なバリデーションは不要です。

実際、email を空欄にした User を作成するようなテストを書いてみると “Email can’t be blank” と出力されます。

     Failure/Error: create(:user, nickname: '太郎', email: '')

     ActiveRecord::RecordInvalid:
       Validation failed: Email can't be blank

このように gem で最初から用意されているメソッド等については
既に gem 側でテストが書かれているため、新たにテストを書かないのが一般的です。

※バリデーションメッセージが画面に表示されるという要件については、今後作っていく System Spec にてテストします。

宿題

RSpec の書き方おさらい

以前、RSpec の章で基本的な RSpec の書き方については主に宿題で学んでいただきましたが、
今回使用したいくつかの書き方は個別に復習しておきましょう。

バリデーション

Rails でモデルに制約をかけることをバリデーションといいますが、もしご存じではない方はこの機会に学んでおきましょう。

テスト駆動開発

今回、失敗するテストを書く => テストを通す => テストをリファクタする、というテスト駆動開発の流れで進めました。

さらっと流れだけを説明しましたが、その考え方自体は大変奥深いものです。
テストを書くことの重要性をご理解いただく意味でも、テスト駆動開発の概念や経緯については一度目を通しておくと勉強になります。

ユーザー関連画面の作成

devise では、最初からそれっぽい画面もある程度用意してくれています。

開発用サーバを bin/dev コマンドで起動し、 /users/sing_up 等にアクセスするとすぐに画面が表示されます。

これでも使えることには使えるのですが、どうしても見た目がよくないためカスタマイズしましょう。
最初はユーザー登録画面をカスタマイズします。

view ファイルを生成

デフォルトで使える(この時点で画面に表示される) view ファイルは gem に組み込まれて編集できません。

devise 固有の view を編集するため、 rails g devise:views コマンドで各種 view ファイルを一旦生成します。

以下の通りコマンドを実行してください。

$ bundle exec rails g devise:views
invoke Devise::Generators::SharedViewsGenerator
create app/views/devise/shared
create app/views/devise/shared/\_error_messages.html.erb
create app/views/devise/shared/\_links.html.erb
invoke form_for
create app/views/devise/confirmations
create app/views/devise/confirmations/new.html.erb
create app/views/devise/passwords
create app/views/devise/passwords/edit.html.erb
create app/views/devise/passwords/new.html.erb
create app/views/devise/registrations
create app/views/devise/registrations/edit.html.erb
create app/views/devise/registrations/new.html.erb
create app/views/devise/sessions
create app/views/devise/sessions/new.html.erb
create app/views/devise/unlocks
create app/views/devise/unlocks/new.html.erb
invoke erb
create app/views/devise/mailer
create app/views/devise/mailer/confirmation_instructions.html.erb
create app/views/devise/mailer/email_changed.html.erb
create app/views/devise/mailer/password_change.html.erb
create app/views/devise/mailer/reset_password_instructions.html.erb
create app/views/devise/mailer/unlock_instructions.html.erb

このうち、取り急ぎ TechLog で使用するのは以下の 3 ファイルです。

  • app/views/devise/registrations/new.html.erb (ユーザー登録ページ)
  • app/views/devise/sessions/new.html.erb (ログインページ)
  • app/views/devise/shared/_error_messages.html.erb (エラーメッセージのパーシャル)

他の view ファイルは devise でのパスワード再設定などで使われるものですが、本カリキュラムでは割愛しているため使わないものです。

使わない view ファイルを残しておくことは混乱の元になるため、削除しておくことをおすすめします。
※ただし、今後自分で学習するために他の画面で使う予定がある場合は、他の view ファイルを残しておいても問題ありません。

画面確認

開発用サーバを bin/dev コマンドで起動し、ユーザー登録画面とログイン画面の初期状態を確認しておきます。

$ bin/dev
10:02:24 web.1  | started with pid 8025
10:02:24 css.1  | started with pid 8026
10:02:26 web.1  | => Booting Puma
10:02:26 web.1  | => Rails 7.0.3 application starting in development
10:02:26 web.1  | => Run `bin/rails server --help` for more startup options
10:02:27 web.1  | Puma starting in single mode...
10:02:27 web.1  | * Puma version: 5.6.4 (ruby 3.0.4-p208) ("Birdie's Version")
10:02:27 web.1  | *  Min threads: 5
10:02:27 web.1  | *  Max threads: 5
10:02:27 web.1  | *  Environment: development
10:02:27 web.1  | *          PID: 8025
10:02:27 web.1  | * Listening on http://127.0.0.1:3000
10:02:27 web.1  | * Listening on http://[::1]:3000
10:02:27 web.1  | Use Ctrl-C to stop
10:02:27 css.1  |
10:02:27 css.1  | Rebuilding...
10:02:27 css.1  | Done in 187ms.

ユーザー登録画面

ユーザー登録画面のパスは /users/sing_up ですので、ブラウザで http://localhost:3000/users/sign_up にアクセスします。

初期状態ではどこが見出しで、どこがボタンかが分かりづらく、また当然ながら英語となっているため以降のカリキュラムで調整します。

ログイン画面

ユーザー登録画面のパスは /users/sing_in ですので、ブラウザで http://localhost:3000/users/sign_in にアクセスします。

こちらもユーザー登録画面と同じく良い見た目とは言えないため、
よりモダンな見た目のログイン画面となるようにカスタマイズしていきます。

ここまでの変更 (view 作成)を一旦コミットしておきましょう。

$ git add .
$ git commit -m "deviseのviewファイル(編集用)を生成"
$ git push

宿題

devise の view ファイル

今回は devise の view ファイルを生成し、見た目をカスタマイズできるようにしてから変更を加えていきました。

viwe と同じく controller についても、同じようにカスタマイズしたい時は一度生成する必要があります。
また、TechLog では使わないため消した view ファイルについても、当然ながら本来は役割があります。

これら、カスタマイズするために生成するファイルごとの役割を一度理解し、全体像を掴んでおくと devise の理解が進みますので以下の記事を一読いただくのがおすすめです。

Tailwind CSS の導入

では、これからユーザー認証関連の画面を装飾していきます。
そのために人気のCSSフレームワークである Tailwind CSS を導入します。

Tailwind CSS とは?

HTML要素の class に直接特定の文字、例えば font-bold と書くことで太字にできるなど
HTMLだけで装飾を完結することができる、最近人気が出ている CSS フレームワークの一つです。

こちらの記事が大変分かりやすいので、是非ご一読ください。

使い方の基本

本アプリでは Tailwind CSS という CSS フレームワークを用いて装飾を行なっていきますが、
今から改めてこのフレームワークを導入する必要はありません。

app/views/home/top.html.erbをみてみましょう

<div>
  <h1 class="font-bold text-4xl">Home#top</h1>
  <p>Find me in app/views/home/top.html.erb</p>
</div>

実はこれは既に Tailwind クラスが最初から書かれています。

rails プロジェクトを作成した時、以下のようなコマンドを実行したことを覚えているでしょうか?

$ bundle exec rails tailwindcss:install

これのおかげです。

font-boldtext-4xlというクラス名に対して CSS は作成してませんでしたね。

Tailwind : font-bold
https://tailwindcss.com/docs/font-weight

左側が Tailwind でのクラス名、右側がこれにより適用される CSS のプロパティ

Tailwind : text-4xl
https://tailwindcss.com/docs/font-size

このように Tailwind クラスは既に書かれていますが、初回起動時はまだ反映されていません。

rails server を起動してみてみましょう。

$ bundle exec rails s
http://127.0.0.1:3000 にアクセスします。

これは、この view ファイルが作成された後にまだ Tailwind のビルドを行なっていないからです。
追加した Tailwind クラスを反映させるには build を行う必要があります。

tailwind の CSS クラスを追加したりしても
view ファイルで使われていない=不要なスタイルは全てパージ(省略)されてしまっているので
何もしていない状態では適用されません。

そのため、view ファイルで使われている Tailwind CSS クラスが適用されるよう、再ビルドする必要があります。
以下のコマンドを打ちます。

$ bundle exec rails tailwindcss:build
+ /Users/tmasuyama1114/tmasuyama_workspace/menta8lesson/source/techlog/techlog-app/.bundle/ruby/3.0.0/gems/tailwindcss-rails-2.0.8-arm64-darwin/exe/arm64-darwin/tailwindcss -i /Users/tmasuyama1114/tmasuyama_workspace/menta8lesson/source/techlog/techlog-app/app/assets/stylesheets/application.tailwind.css -o /Users/tmasuyama1114/tmasuyama_workspace/menta8lesson/source/techlog/techlog-app/app/assets/builds/tailwind.css -c /Users/tmasuyama1114/tmasuyama_workspace/menta8lesson/source/techlog/techlog-app/config/tailwind.config.js --minify

Done in 178ms.

これで app/assets/builds/tailwind.css が更新されます。

もう一度 http://127.0.0.1:3000にアクセスしましょう。

今度は太文字 (font-bold) と文字サイズの変更 (text-4xl)が適用されています。

一旦ターミナルで Ctrl + C を叩いて rails server を停止させましょう。

自動ビルド手順

このままだと毎回 rails s して tailwind:build してとなり、非常に開発体験が悪いですね。
自動ビルド付きの開発用コマンドはこちら。
rails sを実行する代わりに/bin/devというコマンドを実行します。
(bin/ ディレクトリ内のコマンドを実行するため bundle exec は不要)

$ bin/dev
08:27:17 web.1  | started with pid 64414
08:27:17 css.1  | started with pid 64415
08:27:18 web.1  | => Booting Puma
08:27:18 web.1  | => Rails 7.0.3 application starting in development
08:27:18 web.1  | => Run `bin/rails server --help` for more startup options
08:27:18 web.1  | Puma starting in single mode...
08:27:18 web.1  | * Puma version: 5.6.4 (ruby 3.0.4-p208) ("Birdie's Version")
08:27:18 web.1  | *  Min threads: 5
08:27:18 web.1  | *  Max threads: 5
08:27:18 web.1  | *  Environment: development
08:27:18 web.1  | *          PID: 64414
08:27:18 web.1  | * Listening on http://127.0.0.1:3000
08:27:18 web.1  | * Listening on http://[::1]:3000
08:27:18 web.1  | Use Ctrl-C to stop
08:27:19 css.1  |
08:27:19 css.1  | Rebuilding...
08:27:19 css.1  | Done in 183ms.

コンソールのログをみると web.1css.1の両方のログが流れているのが分かるでしょうか。
これは、web というのはrails s、css というのはビルドが走っていることになります。

css の開発サーバーと rails サーバーが同時に立ち上がって、tailwind のスタイル追加の都度、ビルドが走るようになります。
ホットリロードではないためブラウザの更新ボタンを自分で押す必要はありますが、毎回ビルドコマンドを叩かなくてもビルドが実行されるようになります。

試しに赤文字となる Tailwind CSS クラスを追加してみましょう。

https://tailwindcss.jp/docs/text-color
.text-red-500というクラスを指定してみましょう。


app/views/home/top.html.erb の h1 要素に追加してみました。

app/views/home/top.html.erb

<div>
  <h1 class="font-bold text-4xl text-red-500">Home#top</h1>
  <p>Find me in app/views/home/top.html.erb</p>
</div>

この時、 bin/devを起動させていたターミナルで再ビルドが走っていることがわかります。

...
08:38:02 css.1  |
08:38:02 css.1  | Rebuilding...
08:38:02 css.1  | Done in 59ms.

このように、毎回手動でのビルドが不要であるため開発がスムーズです。

さて、再度 http://127.0.0.1:3000/home/top して確認してみます。

赤文字になってますね!

終わったので一旦 ターミナルの bin/devは Ctrl + C で停止しておきましょう。

ここまでの変更をコミット、およびプッシュしておきます。

$ git add .
$ git commit -m "Tailwind CSSクラスを適用"
$ git push

ユーザー登録ページのカスタマイズ

本章ではユーザー登録ページ /users/sign_up で使われる view ファイルを編集し、デザインを調整します。

変更前 view ファイルの確認

devise のコマンドで作成した直後ですと、ユーザー登録用の view ファイルは以下のようになっています。

(変更前): app/views/devise/registrations/new.html.erb

<h2>Sign up</h2>

<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
  <%= render "devise/shared/error_messages", resource: resource %>

  <div class="field">
    <%= f.label :email %><br />
    <%= f.email_field :email, autofocus: true, autocomplete: "email" %>
  </div>

  <div class="field">
    <%= f.label :password %>
    <% if @minimum_password_length %>
    <em>(<%= @minimum_password_length %> characters minimum)</em>
    <% end %><br />
    <%= f.password_field :password, autocomplete: "new-password" %>
  </div>

  <div class="field">
    <%= f.label :password_confirmation %><br />
    <%= f.password_field :password_confirmation, autocomplete: "new-password" %>
  </div>

  <div class="actions">
    <%= f.submit "Sign up" %>
  </div>
<% end %>

<%= render "devise/shared/links" %>

view の修正

では、ユーザー登録の view を次のように変更してください。

(変更後): app/views/devise/registrations/new.html.erb

<%= form_with scope: resource, as: resource_name, url: registration_path(resource_name), class: "space-y-6 w-3/4 max-w-lg" ,local: true do |f| %>
  <%= render "devise/shared/error_messages", resource: resource %>

  <label class="block text-xl font-bold text-gray-700">ユーザー登録</label>
  <div class="mt-1">
    <label class="text-gray-700 text-md">
      メールアドレス
    </label>
    <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 mt-1 block w-full sm:text-sm placeholder-gray-400 border border-gray-300 rounded-md", placeholder: "test@exeample.com" %>
  </div>
  <div class="mt-1">
    <label class="text-gray-700 text-md">
      ニックネーム
    </label>
    <%= f.text_field :nickname, autocomplete: "nickname", class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 mt-1 block w-full sm:text-sm placeholder-gray-400 border border-gray-300 rounded-md", placeholder: "エンジニアの卵" %>
  </div>
  <div class="mt-1">
    <label class="text-gray-700 text-md">
      パスワード (6文字以上)
    </label>
    <%= f.password_field :password, autocomplete: "new-password", class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 mt-1 block w-full sm:text-sm placeholder-gray-400 border border-gray-300 rounded-md", placeholder: "pass1234" %>
  </div>
  <div class="mt-1">
    <label class="text-gray-700 text-md">
      確認用パスワード
    </label>
    <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 mt-1 block w-full sm:text-sm placeholder-gray-400 border border-gray-300 rounded-md", placeholder: "pass1234" %>
  </div>

  <button type="submit" class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
    <%= f.submit "ユーザー登録" %>
  </button>
<% end %>

画面確認

では開発用サーバを起動し、ブラウザで確認していきましょう。

bin/dev コマンドで開発用サーバを起動します。

$ bin/dev
09:33:09 web.1  | started with pid 7086
09:33:09 css.1  | started with pid 7087
09:33:11 web.1  | => Booting Puma
09:33:11 web.1  | => Rails 7.0.3 application starting in development
09:33:11 web.1  | => Run `bin/rails server --help` for more startup options
09:33:11 web.1  | Puma starting in single mode...
09:33:11 web.1  | * Puma version: 5.6.4 (ruby 3.0.4-p208) ("Birdie's Version")
09:33:11 web.1  | *  Min threads: 5
09:33:11 web.1  | *  Max threads: 5
09:33:11 web.1  | *  Environment: development
09:33:11 web.1  | *          PID: 7086
09:33:11 web.1  | * Listening on http://127.0.0.1:3000
09:33:11 web.1  | * Listening on http://[::1]:3000
09:33:11 web.1  | Use Ctrl-C to stop
09:33:12 css.1  |
09:33:12 css.1  | Rebuilding...
09:33:12 css.1  | Done in 201ms.

サーバを起動できたら、ブラウザで http://localhost:3000/users/sign_up にアクセスします。

このような画面になっていれば OK です。

画面全体のレイアウト調整

ユーザー登録画面を見ると分かると思いますが、全体的にコンテンツがブラウザ内の左側に寄ってしまっていますね。

TechLog は 1 カラム構成のアプリケーションであるため、コンテンツが真ん中になるように調整しましょう。

修正するのは、各 view を読み込んでいる大元の view である app/views/layouts/application.html.erb ファイルです。

(変更前): app/views/layouts/application.html.erb

...
  <body>
    <main class="container mx-auto mt-28 px-5 flex">
      <%= yield %>
    </main>
  </body>
...

TailwindCSS を指定して rails new コマンドを実行したため、TailwindCSS 仕様のスタイルクラスが最初から適用されていることが分かります。
今回は真ん中に寄せるスタイルを適用したいので、上記の部分を以下のように変更してください。
(ついでに背景色も変更)

(変更後): app/views/layouts/application.html.erb

...
  <body class="h-screen bg-blue-50">
    <main class="container mx-auto mt-20 py-8 px-5 flex items-center justify-center">
      <%= yield %>
    </main>
  </body>
...

変更したら再度ブラウザで http://localhost:3000/users/sign_up にアクセスします。
コンテンツが真ん中に寄ったことが確認できます。

テスト作成

デザイン調整を行なうにあたり、devise がデフォルトで用意している view ファイルから当然ながら変わっています。
言い換えると、devise の gem 自体でテストされている view とは異なる view を使っております。

そのため、修正した後の画面で想定通りの動作をすることを確認するテストを追加しましょう。

System Spec ファイル作成

トップページ (Home#top) の時と同様、System Spec ファイルをコマンドで生成します。

$ bundle exec rails g rspec:system user
      create  spec/system/users_spec.rb

テスト修正

次は作成されたテストファイル spec/system/users_spec.rb を修正していきましょう。

spec/system/users_spec.rb

require 'rails_helper'

describe 'User', type: :system do
  before { driven_by :selenium_chrome_headless }

  # ユーザー情報入力用の変数
  let(:email) { 'test@example.com' }
  let(:nickname) { 'テスト太郎' }
  let(:password) { 'password' }
  let(:password_confirmation) { password }

  describe 'ユーザー登録機能の検証' do
    before { visit '/users/sign_up' }

    # ユーザー登録を行う一連の操作を subject にまとめる
    subject do
      fill_in 'user_nickname', with: nickname
      fill_in 'user_email', with: email
      fill_in 'user_password', with: password
      fill_in 'user_password_confirmation', with: password_confirmation
      click_button 'ユーザー登録'
    end

    context '正常系' do
      it 'ユーザーを作成できる' do
        expect { subject }.to change(User, :count).by(1) # Userが1つ増える
        expect(current_path).to eq('/') # ユーザー登録後はトップページにリダイレクト
      end
    end
  end
end

ここまでで一旦、テストを実行しておきましょう。

$ bin/rspec spec/system/users_spec.rb
...
User
  ユーザー登録機能の検証
    正常系
DEBUGGER[rspec#59177]: Attaching after process 59169 fork to child process 59177
      ユーザーを作成できる

Finished in 2.66 seconds (files took 0.71727 seconds to load)
1 example, 0 failures

テストに通ることを確認してください。

正常系で問題なかったので、続けて異常系 (メールアドレスが空欄の場合など) に関するテストも追加した全文がこちらです。

require 'rails_helper'

describe 'User', type: :system do
  before { driven_by :selenium_chrome_headless }

  # ユーザー情報入力用の変数
  let(:email) { 'test@example.com' }
  let(:nickname) { 'テスト太郎' }
  let(:password) { 'password' }
  let(:password_confirmation) { password }

  describe 'ユーザー登録機能の検証' do
    before { visit '/users/sign_up' }

    # ユーザー登録を行う一連の操作を subject にまとめる
    subject do
      fill_in 'user_nickname', with: nickname
      fill_in 'user_email', with: email
      fill_in 'user_password', with: password
      fill_in 'user_password_confirmation', with: password_confirmation
      click_button 'ユーザー登録'
    end

    context '正常系' do
      it 'ユーザーを作成できる' do
        expect { subject }.to change(User, :count).by(1) # Userが1つ増える
        expect(current_path).to eq('/') # ユーザー登録後はトップページにリダイレクト
      end
    end

    context '異常系' do
      context 'nicknameが空の場合' do
        let(:nickname) { '' }
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count) # Userが増えない
          expect(page).to have_content("Nickname can't be blank") # エラーメッセージのチェック
        end
      end

      context 'nicknameが20文字を超える場合' do
        let(:nickname) { 'あ' * 21 }
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count)
          expect(page).to have_content('Nickname is too long (maximum is 20 character')
        end
      end

      context 'emailが空の場合' do
        let(:email) { '' }
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count)
          expect(page).to have_content("Email can't be blank")
        end
      end

      context 'passwordが空の場合' do
        let(:password) { '' }
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count)
          expect(page).to have_content("Password can't be blank")
        end
      end

      context 'passwordが6文字未満の場合' do
        let(:password) { 'a' * 5 }
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count)
          expect(page).to have_content('Password is too short (minimum is 6 characters')
        end
      end

      context 'passwordが128文字を超える場合' do
        let(:password) { 'a' * 129 }
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count)
          expect(page).to have_content('Password is too long (maximum is 128 characters)')
        end
      end

      context 'passwordとpassword_confirmationが一致しない場合' do
        let(:password_confirmation) { "#{password}hoge" } # passwordに"hoge"を足した文字列にする
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count)
          expect(page).to have_content("Password confirmation doesn't match Password")
        end
      end
    end
  end
end

なお、バリデーションによるエラーメッセージは現在は英語ですが、TechLog は日本語用のアプリですので後に日本語に修正していきます。
その際、このテストも修正することになるので覚えておきましょう。

テスト実行

すべてのテストを実行し、すべて成功することを確認します。

$ bin/rspec
Finished in 5.06 seconds (files took 0.33117 seconds to load)
14 examples, 0 failures

ここまでの変更をコミットして完了です。

$ git add .
$ git commit -m "ユーザー登録画面の修正"
$ git push

ログインページのカスタマイズ

本章ではログインページ /users/sign_in で使われる view ファイルを編集し、デザインを調整します。

変更前 view ファイルの確認

devise のコマンドで作成した直後ですと、ログイン用の view ファイルは以下のようになっています。

(変更前): app/views/devise/sessions/new.html.erb

<h2>Log in</h2>

<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
  <div class="field">
    <%= f.label :email %><br />
    <%= f.email_field :email, autofocus: true, autocomplete: "email" %>
  </div>

  <div class="field">
    <%= f.label :password %><br />
    <%= f.password_field :password, autocomplete: "current-password" %>
  </div>

  <% if devise_mapping.rememberable? %>
    <div class="field">
      <%= f.check_box :remember_me %>
      <%= f.label :remember_me %>
    </div>
  <% end %>

  <div class="actions">
    <%= f.submit "Log in" %>
  </div>
<% end %>

<%= render "devise/shared/links" %>

また、現時点の見た目はこのようになっています。

ユーザー登録ページと同じく、使いやすい見た目ではないのでこちらも修正していきましょう。

view の修正

では、ログインの view を次のように変更してください。

(変更後): app/views/devise/sessions/new.html.erb

<%= form_with scope: resource, as: resource_name, url: session_path(resource_name), class: "space-y-6 w-1/2 max-w-sm", local: true do |f| %>
  <label class="block text-xl font-bold text-gray-700">ログイン</label>
  <div class="mt-1">
    <label class="text-gray-700 text-md">
      メールアドレス
    </label>
    <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 mt-1 block w-full sm:text-sm placeholder-gray-400 border border-gray-300 rounded-md", placeholder: "test@example.com" %>
  </div>
  <div class="mt-1">
    <label class="text-gray-700 text-md">
      パスワード
    </label>
    <%= f.password_field :password, autocomplete: "password", class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 mt-1 block w-full sm:text-sm placeholder-gray-400 border border-gray-300 rounded-md", placeholder: "pass1234" %>
  </div>

  <button type="submit" class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
    <%= f.submit "ログイン" %>
  </button>
<% end %>

画面確認

では開発用サーバを起動し、ブラウザで確認していきましょう。

bin/dev コマンドで開発用サーバを起動します。

$ bin/dev
09:33:09 web.1  | started with pid 7086
09:33:09 css.1  | started with pid 7087
09:33:11 web.1  | => Booting Puma
09:33:11 web.1  | => Rails 7.0.3 application starting in development
09:33:11 web.1  | => Run `bin/rails server --help` for more startup options
09:33:11 web.1  | Puma starting in single mode...
09:33:11 web.1  | * Puma version: 5.6.4 (ruby 3.0.4-p208) ("Birdie's Version")
09:33:11 web.1  | *  Min threads: 5
09:33:11 web.1  | *  Max threads: 5
09:33:11 web.1  | *  Environment: development
09:33:11 web.1  | *          PID: 7086
09:33:11 web.1  | * Listening on http://127.0.0.1:3000
09:33:11 web.1  | * Listening on http://[::1]:3000
09:33:11 web.1  | Use Ctrl-C to stop
09:33:12 css.1  |
09:33:12 css.1  | Rebuilding...
09:33:12 css.1  | Done in 201ms.

サーバを起動できたら、ブラウザで http://localhost:3000/users/sign_in にアクセスします。

このような画面になっていれば OK です。

※前回のレッスンでユーザー登録を試していた場合、ログイン状態が残りトップにリダイレクトされてしまいます。
その場合、Chrome のシークレットウィンドウを活用してください。
(この後のカリキュラムでログアウトボタンを実装することで解消します。)

テスト修正

ユーザー認証機能関連の System Spec は前回作成した spec/system/users_spec.rb で扱っているので、ログイン機能のテスト機能についても同じファイルに追記します。

describe 'ユーザー登録機能の検証' のブロック (do ~ end) の下にテストを追加していきます。

spec/system/users_spec.rb

...
      context 'passwordとpassword_confirmationが一致しない場合' do
        let(:password_confirmation) { "#{password}hoge" } # passwordに"hoge"を足した文字列にする
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count)
          expect(page).to have_content("Password confirmation doesn't match Password")
        end
      end
    end
  end
  ######## ここから追記 #######
  describe 'ログイン機能の検証' do
    # 事前にユーザー作成
    before do
      create(:user, nickname: nickname, email: email, password: password, password_confirmation: password) # 事前にユーザー作成

      visit '/users/sign_in'
      fill_in 'user_email', with: email
      fill_in 'user_password', with: 'password'
      click_button 'ログイン'
    end

    context '正常系' do
      it 'ログインに成功し、トップページにリダイレクトする' do
        expect(current_path).to eq('/')
      end
    end

    context '異常系' do
      let(:password) { 'NGpassword' }
      it 'ログインに失敗し、ページ遷移しない' do
        expect(current_path).to eq('/users/sign_in')
      end
    end
  end

テスト実行

では、すべてのテストに通ることを確認しましょう。

$ bin/rspec
...
  ログイン機能の検証
    正常系
      ログインに成功し、トップページにリダイレクトする
    異常系
      ログインに失敗し、ページ遷移しない

Finished in 6.06 seconds (files took 0.42416 seconds to load)
16 examples, 0 failures

コケるテストがないことを確認できれば OK です。

ここまでの変更をコミットして完了です。

$ git add .
$ git commit -m "ログイン画面の修正"
$ git push

フラッシュメッセージ追加

現状、ログインページからログインしようとした際に認証情報が誤っていても、何も表示されません。
また、ログインに成功したとしてもただトップページにリダイレクトされるように見え、ログインできているかが分かりません。

これだとユーザーからはログイン機能の使い勝手が分かりにくいため、
ログイン成功時・失敗時には、その旨が画面上部にフラッシュメッセージとして表示されるようにしましょう。

テスト修正

そろそろ RSpec を使ったテストの書き方にも慣れてきたと思うので、
テスト駆動開発の方式に倣って先にテストを修正して仕様を確定させましょう。

spec/system/users_spec.rb の中で
describe 'ログイン機能の検証'というブロックにテストを追加していきます。

修正後のテストは以下のようになります。

spec/system/users_spec.rb

  ...
  describe 'ログイン機能の検証' do
    before do
      create(:user, nickname: nickname, email: email, password: password, password_confirmation: password)

      visit '/users/sign_in'
      fill_in 'user_email', with: email
      fill_in 'user_password', with: 'password'
      click_button 'ログイン'
    end

    context '正常系' do
      it 'ログインに成功し、トップページにリダイレクトする' do
        expect(current_path).to eq('/')
      end

      it 'ログイン成功時のフラッシュメッセージを表示する' do # 追加
        expect(page).to have_content('Signed in successfully')
      end
    end

    context '異常系' do
      let(:password) { 'NGpassword' }
      it 'ログインに失敗し、ページ遷移しない' do
        expect(current_path).to eq('/users/sign_in')
      end

      it 'ログイン失敗時のフラッシュメッセージを表示する' do  # 追加
        expect(page).to have_content('Invalid Email or password')
      end
    end
  end

この時点では、まだフラッシュメッセージを実装していないためテストにコケることを確認してください。

$ bin/rspec
...
Finished in 10.2 seconds (files took 0.31626 seconds to load)
12 examples, 2 failures

Failed examples:

rspec ./spec/system/users_spec.rb:105 # User ログイン機能の検証 正常系 ログイン失敗時のフラッシュメッセージを表示する
rspec ./spec/system/users_spec.rb:116 # User ログイン機能の検証 異常系 ログイン失敗時のフラッシュメッセージを表示する

次にフラッシュメッセージを実装し、テストを通していきます。

実装追加

部分テンプレート作成

まずは、フラッシュメッセージを表示するための部分テンプレートを作成します。

app/views/sharedというディレクトリを作成し、 _flash.html.erbというファイルを作成してください。
中身は以下の通りにします。

app/views/shared/_flash.html.erb

<% if flash[:notice] %>
  <div class="p-4 mt-3 mb-4 mx-3 text-sm text-blue-700 bg-blue-100 rounded-lg dark:bg-blue-200 dark:text-blue-800" role="alert">
    <%= flash[:notice] %>
  </div>
<% end %>
<% if flash[:alert] %>
  <div class="p-4 mt-3 mb-4 mx-3 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
    <%= flash[:alert] %>
  </div>
<% end %>

部分テンプレート読み込み

フラッシュメッセージ用の部分テンプレートはすべての画面で使い回すものですので、
すべての画面に表示するための view である app/views/layouts/application.html.erb に手を加えます。

以下のように、main タグの上に 1 行を追加してください。

app/views/layouts/application.html.erb

...
<body class="h-screen bg-blue-50">
  <%= render 'shared/flash' %>
  <main class="container mx-auto mt-20 py-8 px-5 flex items-center justify-center">
    <%= yield %>
  </main>
</body>

これで、フラッシュメッセージに内容が入ってきた時には常に一番上に表示されるようになります。

テスト実行 (成功)

実装は完成したはずなので、テストを実行しておきましょう。

もちろん、先にブラウザを使って手動で動作確認もできるためそれでも OK なのですが、
表示有無の機能自体については RSpec での自動テストの方が早く確実にチェックできるため、自動テストに頼ることをおすすめします。

$ bin/rspec spec/system/users_spec.rb
...
  ログイン機能の検証
    正常系
      ログインに成功し、トップページにリダイレクトする
      ログイン成功時のフラッシュメッセージを表示する
    異常系
      ログインに失敗し、ページ遷移しない
      ログイン失敗時のフラッシュメッセージを表示する

Finished in 6.22 seconds (files took 0.39357 seconds to load)
12 examples, 0 failures

テストに無事に通ることを確認しました。

次は、RSpec のテストでは確認していない見た目について、ブラウザでチェックします。

動作確認

では、ログインページ http://localhost:3000/users/sign_in にアクセスして確認していきます。
(ログイン済みの場合は、ログアウトしておきましょう)

まずは、ログインフォームに何も入力せずに「ログイン」ボタンを押してください。
メールアドレス、またはパスワードが無効である旨の (alert レベルの)フラッシュメッセージが表示されます。

image

次に、エラーがなかった場合のフラッシュメッセージを確認します。
作成済みユーザーの認証情報を用いてログインし、ログインに成功した旨の (notice レベルの)フラッシュメッセージが表示されることを確認します。
(一度もユーザーを作成していなければ、先に /users/sign_up から一度ユーザーを作成しておきましょう)

image

これで、フラッシュメッセージを表示させるという仕様は実現できていることを画面からも確認できました。

ここまで完了したら、変更をコミットしておきましょう。

$ git add .
$ git commit -m "フラッシュメッセージを表示できるように変更"
$ git push

宿題

部分テンプレート

Rails のアプリでは、view を使いまわせるところは使い回し、記述の重複を出来るだけ少なくすることを基本的に目指します。
そのため、今回作った部分テンプレートが便利になります。

部分テンプレートは基本中の基本ですので、使い方を忘れている場合は復習しておきましょう。

flash とは

Rails における flash とは、アクション実行後にメッセージを一時的に表示させる仕組みです。

devise を使うと flash に関する設定は特にしなくても使えましたが、devise に関連しないアクションで使う時には自分で設定する必要があります。
よく使われる仕組みですので、この機会に覚えておきましょう。

ナビゲーションバー作成

今回は、ユーザー登録ページ (/users/sign_up) やログインページ (/users/sign_in) へのリンクを置くナビゲーションバーを作成しましょう。

仕様の確認

先に完成形を確認しておきます。
ログインしている時と、ログインしていない時では必要なリンクが異なるので使い分けていきます。

ログインしていない場合

ユーザー登録、ログイン画面へのリンクが表示されます。

image

ログインしている場合

ログアウトリンクが表示されます。

image

RSpec でのログイン設定

上記の仕様の通り、今回は「ログインしているかどうか」によって表示されるリンクが変わります。
しかし、テスト内でログインが必要が操作をする前に毎回ログインフォームからログインをするのは面倒です。

これを解決するために、devise のヘルパーメソッドを使えるようにします。
この設定をしておくと sign_in user という形で 1 行記述するだけで、RSpec テスト内でログインした状態を実現できます。

そのため、spec/rails_helper.rbの最後の方で Devise::Test::IntegrationHelpersというモジュールを include しておきます。

spec/rails_helper.rb

  ...
  config.include FactoryBot::Syntax::Methods

  config.include Devise::Test::IntegrationHelpers, type: :system # 追加
end

これで System Spec の中で sign_in userという 1 行で、user でログインした状態を実現することが出来ます。
※事前に user には User オブジェクトを代入しておく必要はあります。

このように devise gem には便利なヘルパーメソッドがいくつか用意されているので、知れば知るほど活用の幅が広がります。

テスト追加

どの画面でも共通的に表示するものですので、どの画面の System Spec でチェックしても問題ありませんが、
例えばログイン画面はログインしているとトップページへリダイレクトされてしまうため、チェックには不向きです。

今回は既に作ってあるトップページ (Home#top) の System Spec を修正しましょう。

spec/system/home_spec.rb

require 'rails_helper'

RSpec.describe 'Home', type: :system do
  before do
    driven_by :selenium_chrome_headless
  end

  describe 'トップページアクセスの検証' do
    it 'Home#top という文字列が表示される' do
      visit '/'

      expect(page).to have_content('Home#top')
    end
  end

  ######## ここから追加 #######
  describe 'ナビゲーションバーの検証' do
    context 'ログインしていない場合' do
      before { visit '/' }

      it 'ユーザー登録リンクを表示する' do
        expect(page).to have_link('ユーザー登録', href: '/users/sign_up')
      end

      it 'ログインリンクを表示する' do
        expect(page).to have_link('ログイン', href: '/users/sign_in')
      end

      it 'ログアウトリンクは表示しない' do
        expect(page).not_to have_content('ログアウト')
      end
    end

    context 'ログインしている場合' do
      before do
        user = create(:user) # ログイン用のユーザーを作成
        sign_in user # 作成したユーザーでログイン
        visit '/'
      end

      it 'ユーザー登録リンクは表示しない' do
        expect(page).not_to have_link('ユーザー登録', href: '/users/sign_up')
      end

      it 'ログインリンクは表示しない' do
        expect(page).not_to have_link('ログイン', href: '/users/sign_in')
      end

      it 'ログアウトリンクを表示する' do
        expect(page).to have_content('ログアウト')
      end

      it 'ログアウトリンクが機能する' do
        click_button 'ログアウト'

        # ログインしていない状態のリンク表示パターンになることを確認
        expect(page).to have_link('ユーザー登録', href: '/users/sign_up')
        expect(page).to have_link('ログイン', href: '/users/sign_in')
        expect(page).not_to have_content('ログアウト')
      end
    end
  end
end

この時点では当然、テストがコケることを確認します。
ただし、一部リンクが「表示されないこと」を確認しているテストもあるため、一部のテストはコケないことに注意してください。

$ bin/rspec spec/system/home_spec.rb
...
Finished in 11.44 seconds (files took 0.45402 seconds to load)
8 examples, 4 failures

Failed examples:

rspec ./spec/system/home_spec.rb:20 # Home ナビゲーションバーの検証 ログインしていない場合 ユーザー登録リンクを表示する
rspec ./spec/system/home_spec.rb:24 # Home ナビゲーションバーの検証 ログインしていない場合 ログインリンクを表示する
rspec ./spec/system/home_spec.rb:48 # Home ナビゲーションバーの検証 ログインしている場合 ログアウトリンクを表示する
rspec ./spec/system/home_spec.rb:52 # Home ナビゲーションバーの検証 ログインしている場合 ログアウトリンクが機能する

実装

では、上記のテストが通るように設定していきましょう。

部分テンプレート作成

app/views/shared ディレクトリに _navbar.html.erb というファイルを作成します。

<nav class="bg-gray-800 border-gray-200 px-2 sm:px-4 py-2.5">
  <div class="container flex flex-wrap justify-between items-center mx-auto">
    <%= link_to "TechLog", "/", class: "self-center text-white text-xl font-semibold whitespace-nowrap" %>
    <div class="w-full md:block md:w-auto">
      <ul class="flex flex-col mt-4 md:flex-row md:space-x-8 md:mt-0 md:text-sm md:font-medium">
        <% if current_user %>
          <li>
            <%= button_to "ログアウト", destroy_user_session_path, class: "block py-2 pr-4 pl-3 text-gray-400 hover:text-white border-b border-gray-700 hover:bg-gray-700 md:hover:bg-transparent md:border-0 md:hover:text-blue-white md:p-0", method: :delete %>
          </li>
        <% else %>
          <li>
            <%= link_to "ユーザー登録", new_user_registration_path, class: "block py-2 pr-4 pl-3 text-gray-400 hover:text-white border-b border-gray-700 hover:bg-gray-700 md:hover:bg-transparent md:border-0 md:hover:text-blue-white md:p-0" %>
          </li>
          <li>
            <%= link_to "ログイン", new_user_session_path, class: "block py-2 pr-4 pl-3 text-gray-400 hover:text-white border-b border-gray-700 hover:bg-gray-700 md:hover:bg-transparent md:border-0 md:hover:text-blue-white md:p-0" %>
          </li>
        <% end %>
      </ul>
    </div>
  </div>
</nav>

ちなみに、ログアウトリンクだけは link_to ではなく button_to となっていることに注意してください。
これは、デフォルトでは link_tomethod: :delete をただ追加しても DELET メソッドを実現できないためです。

さらに、フラッシュメッセージの部分テンプレートを読み込んだ時と同じく
今回作成した部分テンプレートを app/views/layouts/application.html.erb に追記します。

app/views/layouts/application.html.erb

...
  <body class="h-screen bg-blue-50">
    <%= render 'shared/flash' %>
    <%= render 'shared/navbar' %> <%# 追記 %>
    <main class="container mx-auto mt-20 py-8 px-5 flex items-center justify-center">
      <%= yield %>
    </main>
  </body>
</html>

テスト実行

実装が完了しましたので、テストがすべて成功することを確認します。

$ bin/rspec spec/system/home_spec.rb
...
  ナビゲーションバーの検証
    ログインしていない場合
      ユーザー登録リンクを表示する
      ログインリンクを表示する
      ログアウトリンクは表示しない
    ログインしている場合
      ユーザー登録リンクは表示しない
      ログインリンクは表示しない
      ログアウトリンクを表示する
      ログアウトリンクが機能する

Finished in 3.45 seconds (files took 0.41146 seconds to load)
8 examples, 0 failures

動作確認

すべてのテストに通りましたが、見た目としても問題ないことを確認します。

bin/devコマンドで開発用サーバを起動してから、各リンクを確認していきます。

ログインしていない場合

最初はログアウトした状態で、ユーザー登録リンクとログインリンクが表示されることを確認します。

また、それぞれのリンクをクリックし、各ページへ遷移できることも確認しておくといいでしょう。

ログインしている場合

今度はログインした時のナビゲーションバーを確認します。
ログアウトリンクが表示されていれば OK です。

ログアウトリンクをクリックするとログアウトされ、ユーザー登録とログインリンクも表示されるようになることを確認しておきます。

これでナビゲーションバーを実装は完了です。
変更をコミットしておきます。

$ git add .
$ git commit -m "ナビゲーションバーを追加"
$ git push

宿題

have_link マッチャー

今回、Capybara でユーザー登録・ログインのテキストリンクを探すために have_link というマッチャを使いました。

have_link マッチャがどういう要素を見つけてくるのかは、前にも紹介した人気の Qiita 記事で復習しておきましょう。

deviseのRSpec用ヘルパー

RSpec の中でログイン状態を実現するためのヘルパーを使いましたね。
もう少し簡単なアプリケーションで導入する時の流れを全体像で掴んでおいた方が理解が進むので、こちらの記事で流れを改めて把握しておきましょう。

エラーメッセージのデザイン

今回は小さな変更ですが、バリデーションによるエラーメッセージの見た目を整えていきましょう。

現状の確認

開発用サーバを bin/dev で起動してから、何も入力せずにユーザー登録してみましょう。
バリデーションによるエラーメッセージが表示されますね。

image

これでもエラーメッセージとは機能しますが、もう少し目立つ形に変更します。

デザイン調整

devise でバリデーションのエラーメッセージを表示する部分テンプレートは app/views/devise/shared/_error_messages.html.erb です。

こちらの現状を確認します。

(変更前): app/views/devise/shared/_error_messages.html.erb

<% if resource.errors.any? %>
  <div id="error_explanation">
    <h2>
      <%= I18n.t("errors.messages.not_saved",
                 count: resource.errors.count,
                 resource: resource.class.model_name.human.downcase)
       %>
    </h2>
    <ul>
      <% resource.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
    </ul>
  </div>
<% end %>

devise コマンドで自動生成されたものですが、特にスタイルは適用されていませんね。

このテンプレートを TailwindCSS で装飾し、以下のように変更します。

(変更後): app/views/devise/shared/_error_messages.html.erb

<% if resource.errors.any? %>
  <div id="error_explanation">
    <h2 class="text-lg mb-3 font-medium text-gray-700">
      <%= I18n.t("errors.messages.not_saved",
                 count: resource.errors.count,
                 resource: resource.class.model_name.human.downcase)
       %>
    </h2>
    <ul>
      <% resource.errors.full_messages.each do |message| %>
        <p class="leading-5 mb-2 text-red-500"><%= message %></p>
      <% end %>
    </ul>
  </div>
<% end %>

変更後の確認

では、再度エラーメッセージを表示させてみましょう。

分かりやすいところでいえば、エラーメッセージが赤くなっていることが分かるかと思います。

image

今回の変更をコミットしておきましょう。

$ git add .
$ git commit -m "バリデーションエラーメッセージのデザイン調整"
$ git push

Devise 日本語化

現時点では devise に関するエラーメッセージはデフォルトの英語のままとなっています。
例えば、空欄を許可しないカラムについては “can’t be blank” と表示されます。
(前回、User.nickname のテストを書いた時にはそれを利用していました)

image

しかし TechLog は日本語の Web アプリですので
devise 関連で表示されるエラーメッセージは日本語化することで、
ユーザーがより使いやすいアプリケーションにしていきます。

テスト修正

今回もテスト駆動開発の流れに則り、先にあるべき姿(仕様)をテストで定義しましょう。

日本語化対応を行った結果、各種エラーメッセージは日本語が表示されることを期待したテストに修正します。
devise 関連のエラーメッセージのチェックはモデルの Spec・System Spec の両方で行なっていたため、それぞれ修正しましょう。

spec/models/user_spec.rb

require 'rails_helper'

describe User do
  let(:nickname) { 'テスト太郎' }
  let(:email) { 'test@example.com' }
  let(:user) { User.new(nickname: nickname, email: email, password: password, password_confirmation: password) } # 変数に格納

  describe '.first' do
    before do
      create(:user, nickname: nickname, email: email)
    end

    subject { described_class.first }

    it '事前に作成した通りのUserを返す' do
      expect(subject.nickname).to eq('テスト太郎')
      expect(subject.email).to eq('test@example.com')
    end
  end

  describe 'validation' do
    let(:password) { '12345678' }

    describe 'nickname属性' do
      describe '文字数制限の検証' do
        context 'nicknameが20文字以下の場合' do
          let(:nickname) { 'あいうえおかきくけこさしすせそたちつてと' } # 20文字

          it 'User オブジェクトは有効である' do
            expect(user.valid?).to be(true)
          end
        end

        context 'nicknameが20文字を超える場合' do
          let(:nickname) { 'あいうえおかきくけこさしすせそたちつてとな' } # 21文字

          it 'User オブジェクトは無効である' do
            user.valid?

            expect(user.valid?).to be(false)
            expect(user.errors[:nickname]).to include('は20文字以下に設定して下さい。')
          end
        end
      end

      describe '存在性の検証' do
        context 'nicknameが空欄の場合' do
          let(:nickname) { '' }

          it 'User オブジェクトは無効である' do
            expect(user.valid?).to be(false)
            expect(user.errors[:nickname]).to include("が入力されていません。")
          end
        end
      end
    end
  end
end

spec/system/users_spec.rb

require 'rails_helper'

describe 'User', type: :system do
  before { driven_by :selenium_chrome_headless }

  # ユーザー情報入力用の変数
  let(:email) { 'test@example.com' }
  let(:nickname) { 'テスト太郎' }
  let(:password) { 'password' }
  let(:password_confirmation) { password }

  describe 'ユーザー登録機能の検証' do
    before { visit '/users/sign_up' }

    # ユーザー登録を行う一連の操作を subject にまとめる
    subject do
      fill_in 'user_nickname', with: nickname
      fill_in 'user_email', with: email
      fill_in 'user_password', with: password
      fill_in 'user_password_confirmation', with: password_confirmation
      click_button 'ユーザー登録'
    end

    context '正常系' do
      it 'ユーザーを作成できる' do
        expect { subject }.to change(User, :count).by(1) # Userが1つ増える
        expect(page).to have_content('ユーザー登録に成功しました。')
        expect(current_path).to eq('/') # ユーザー登録後はトップページにリダイレクト
      end
    end

    context '異常系' do
      context 'エラー理由が1件の場合' do
        let(:nickname) { '' }
        it 'ユーザー作成に失敗した旨のエラーメッセージを表示する' do
          subject
          expect(page).to have_content('エラーが発生したためユーザーは保存されませんでした。')
        end
      end

      context 'エラー理由が2件以上の場合' do
        let(:nickname) { '' }
        let(:email) { '' }
        it '問題件数とともに、ユーザー作成に失敗した旨のエラーメッセージを表示する' do
          subject
          expect(page).to have_content('エラーが発生したためユーザーは保存されませんでした。')
        end
      end

      context 'nicknameが空の場合' do
        let(:nickname) { '' }
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count) # Userが増えない
          expect(page).to have_content('ニックネーム が入力されていません。') # エラーメッセージのチェック
        end
      end

      context 'nicknameが20文字を超える場合' do
        let(:nickname) { 'あ' * 21 }
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count)
          expect(page).to have_content('ニックネーム は20文字以下に設定して下さい。')
        end
      end

      context 'emailが空の場合' do
        let(:email) { '' }
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count)
          expect(page).to have_content('メールアドレス が入力されていません。')
        end
      end

      context 'passwordが空の場合' do
        let(:password) { '' }
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count)
          expect(page).to have_content('パスワード が入力されていません。')
        end
      end

      context 'passwordが6文字未満の場合' do
        let(:password) { 'a' * 5 }
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count)
          expect(page).to have_content('パスワード は6文字以上に設定して下さい。')
        end
      end

      context 'passwordが128文字を超える場合' do
        let(:password) { 'a' * 129 }
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count)
          expect(page).to have_content('パスワード は128文字以下に設定して下さい。')
        end
      end

      context 'passwordとpassword_confirmationが一致しない場合' do
        let(:password_confirmation) { "#{password}hoge" } # passwordに"hoge"を足した文字列にする
        it 'ユーザーを作成せず、エラーメッセージを表示する' do
          expect { subject }.not_to change(User, :count)
          expect(page).to have_content('確認用パスワード が一致していません。')
        end
      end
    end
  end

  describe 'ログイン機能の検証' do
    before do
      create(:user, nickname: nickname, email: email, password: password, password_confirmation: password) # ユーザー作成

      visit '/users/sign_in'
      fill_in 'user_email', with: email
      fill_in 'user_password', with: 'password'
      click_button 'ログイン'
    end

    context '正常系' do
      it 'ログインに成功し、トップページにリダイレクトする' do
        expect(current_path).to eq('/')
      end

      it 'ログイン成功時のフラッシュメッセージを表示する' do
        expect(page).to have_content('ログインしました。')
      end
    end

    context '異常系' do
      let(:password) { 'NGpassword' }
      it 'ログインに失敗し、ページ遷移しない' do
        expect(current_path).to eq('/users/sign_in')
      end

      it 'ログイン失敗時のフラッシュメッセージを表示する' do
        expect(page).to have_content('メールアドレスまたはパスワードが違います。')
      end
    end
  end

  describe 'ログアウト機能の検証' do
    before do
      user = create(:user, nickname: nickname, email: email, password: password, password_confirmation: password) # ユーザー作成
      sign_in user # 作成したユーザーでログイン
      visit '/'
      click_button 'ログアウト'
    end

    it 'トップページにリダイレクトする' do
      expect(current_path).to eq('/')
    end

    it 'ログアウト時のフラッシュメッセージを表示する' do
      expect(page).to have_content('ログアウトしました。')
    end
  end
end

また、前のカリキュラムでナビゲーションバーを追加したことでログアウトの操作を出来るようになったため、
ログアウト時のメッセージを検証するテストも追加してあります。

現時点ではもちろんテストが失敗することを確認してください。

$ bin/rspec spec/system/users_spec.rb
...
Finished in 32.33 seconds (files took 0.45752 seconds to load)
16 examples, 12 failures

次は、これらのテストを通すために各種設定をしていきましょう。

アプリ全体の日本語化設定

Rails の言語は config/application.rb で設定します。
デフォルトでは英語ですが、次のように 1 行を追加することで日本語として設定されます。

...
module TechLog
  class Application < Rails::Application
    ...
    config.i18n.default_locale = :ja # 追記
  end
end

これにより devise も日本語のエラーメッセージを表示しようとするのですが、
devise の gem 自体には日本語のエラーメッセージが含まれていません。

翻訳ファイルを用意

次に、英語と日本語の対訳を .yml 形式のファイルで置いてあげます。

config/localesというディレクトリの直下にdevise.views.ja.ymlというファイルを作成し、
中身を次のように編集します。

config/locales/devise.views.ja.yml ※新規作成

ja:
  activerecord:
    errors:
      models:
        user:
          attributes:
            email:
              taken: "は既に使用されています。"
              blank: "が入力されていません。"
              invalid: "は有効でありません。"
            nickname:
              blank: "が入力されていません。"
              too_long: "は%{count}文字以下に設定して下さい。"
            password:
              blank: "が入力されていません。"
              too_short: "は%{count}文字以上に設定して下さい。"
              too_long: "は%{count}文字以下に設定して下さい。"
              invalid: "は有効でありません。"
            password_confirmation:
              confirmation: "が一致していません。"
    attributes:
      user:
        nickname: "ニックネーム"
        email: "メールアドレス"
        password: "パスワード"
        password_confirmation: "確認用パスワード"
    models:
      user: "ユーザー"
  errors:
    messages:
      not_saved: "エラーが発生したため%{resource}は保存されませんでした。"

  devise:
    failure:
      invalid: "%{authentication_keys}またはパスワードが違います。"
    registrations:
      user:
        signed_up: ユーザー登録に成功しました。
    sessions:
      new:
        sign_in: ログイン
      signed_in: ログインしました。
      user:
        signed_out: ログアウトしました。

このように .yml 形式で、エラー内容を階層に分けて日本語訳を記載するというのは、devise のお作法に則ったやり方となります。
現在はユーザー登録・ログイン・ログアウト機能しか実装していませんが、パスワード再設定など devise 特有の機能を利用するときには本ファイルを編集することも覚えておきましょう。

テストを実行

さて、ブラウザで動作確認する前にテストを実行してみましょう。

実はこの時点でコケるテストが一つあります。

$ bin/rspec
...
Failures:

  1) Home ナビゲーションバーの検証 ログインしている場合 ログアウトリンクが機能する
     Failure/Error: expect(page).not_to have_content('ログアウト')
       expected not to find text "ログアウト" in "TechLog\nユーザー登録\nログイン\nログアウトしました。\nHome#top\nFind me in app/views/home/top.html.erb"
...
Failed examples:

rspec ./spec/system/home_spec.rb:52 # Home ナビゲーションバーの検証 ログインしている場合 ログアウトリンクが機能する

ナビゲーションバーを追加した時のテストがコケてしまいました。

テストのエラーメッセージをチェックしてみます。

     Failure/Error: expect(page).not_to have_content('ログアウト')
       expected not to find text "ログアウト" in "TechLog\nユーザー登録\nログイン\nログアウトしました。\nHome#top\nFind me in app/views/home/top.html.erb"

expected not to find text "ログアウト"... という一文に注目してください。
「ログアウトというテキストが含まれないことを期待していたが、実際には含まれていた」というエラーです。

ここでテストの中身を確認してみます。

spec/system/home_spec.rb

...
      it 'ログアウトリンクが機能する' do
        click_button 'ログアウト'

        # ログインしていない状態のリンク表示パターンになることを確認
        expect(page).to have_link('ユーザー登録', href: '/users/sign_up')
        expect(page).to have_link('ログイン', href: '/users/sign_in')
        expect(page).not_to have_content('ログアウト')****
      end
...

上記のうち expect(page).not_to have_content('ログアウト') という箇所になります。

これは、ログアウトのリンク有無の識別として「ログアウトという文字があるかどうか」を基準にしていました。
しかし今回、ログアウト成功時のフラッシュメッセージを「ログアウトしました。」というテキストに設定したため、この中の「ログアウト」をログアウトリンクとして認識されています。

原因はテストの書き方にあることが分かりましたので、問題のあったテスト 1 行を修正しましょう。

幸い、ログアウトリンクは type が submit でしたので、Capybara の have_button マッチャを使用します。
これを用い、次のように変更します。

spec/system/home_spec.rb

...
      it 'ログアウトリンクが機能する' do
        click_button 'ログアウト'

        # ログインしていない状態のリンク表示パターンになることを確認
        expect(page).to have_link('ユーザー登録', href: '/users/sign_up')
        expect(page).to have_link('ログイン', href: '/users/sign_in')
        expect(page).not_to have_button('ログアウト') # 修正
      end
...

修正後、先ほどコケていたテストも含めすべてのテストに通ることを確認しましょう。

$ bin/rspec
...
Finished in 9.45 seconds (files took 0.41715 seconds to load)
29 examples, 0 failures

最後に、実際に画面上ではどのように変わっているかをブラウザで確認していきます。
テストで通っていれば機能要件は満たせているものの、見た目として違和感がないかをチェックすることも重要です。

動作確認

bin/devコマンドで開発用サーバを起動し、確認します。
(config/application.rb に変更を行った場合は開発用サーバの再起動が必要になるため、前回から起動したままだった場合は再起動してください)

ユーザー登録、ログイン、ログアウトそれぞれで出るメッセージがすべて日本語になっていることを確認しましょう。

ユーザー登録

http://localhost:3000/users/sign_up にアクセスしましょう。

最初はすべての欄を空にしたまま、ユーザー登録に失敗します。

image

意図したメッセージが表示されています。

次は、ちゃんとした情報を埋めてユーザー登録に成功しましょう。

image

成功時のメッセージがバッチリ表示されていますね。

ログイン

http://localhost:3000/users/sign_in にアクセスしましょう。

最初は適当な認証情報でログインに失敗します。

ログイン失敗時

image

認証に失敗した理由とともに、エラーメッセージが出ていますね。

次は作成したユーザーの認証情報でログインします。

ログイン成功時

image

「ログインしました。」と表示されていますね。

ログアウト

最後に、ログアウト時のメッセージが翻訳できていることを確認しましょう。
ナビゲーションバーからログアウトリンクを選択します。

image

「ログアウトしました。」と表示されていれば OK です。

ここまでの変更をコミットしておきます。

$ git add .
$ git commit -m "devise関連のメッセージを日本語化"
$ git push

これで devise ユーザー認証周りの変更は一旦おしまいです。
ボリュームが多く、devise のルールを覚えるのは大変だったかと思います。
お疲れ様でした!

宿題

devise-i18n

devise-i18n というまた別な gem を使うと、翻訳ファイルを自分で用意しなくてもコマンドで生成したりもできます。
(ただし、不要な翻訳の取捨選択が必要なことや、ブラックボックス化を避けるため今回は使いませんでした)

gem の使い方を調べる前提とはなりますが、興味がある方は参考にしてみてください。

have_button

今回、Capybara でログアウトリンク(ボタン)を探すために have_button というマッチャを使いました。

have_button マッチャがどういう要素を見つけてくるのかは、前にも紹介した人気の Qiita 記事で復習しておきましょう。

Post モデル作成

いよいよ、TechLog のメイン機能である学習ログを投稿・閲覧する機能を作成していきます。

本カリキュラムは Rails の基礎は学んでいることを前提としており、
基本的な機能の実装については細かい解説はしていませんのでご注意ください。

それよりもむしろ、どうやって機能を正確にテストするかという「実践的な開発」部分に着目していただければと思います。

モデル作成

TechLog では、学習ログの投稿一つひとつを Post というモデルオブジェクトとして扱うことにします。

rails g コマンドで Post モデル関連のファイルを作成しましょう。

$ bundle exec rails g model Post
      invoke  active_record
      create    db/migrate/20220727120112_create_posts.rb
      create    app/models/post.rb
      invoke    rspec
      create      spec/models/post_spec.rb
      invoke      factory_bot
      create        spec/factories/posts.rb

次は、作られたファイルからテーブルを準備(マイグレーション)します。

マイグレーション

マイグレーションファイルの編集

Post モデル、もとい posts テーブルに持たせたいカラム名をマイグレーションファイル内で定義します。

先ほど作成されたファイルのうち db/migrate/xxxxxxxxxx_create_posts.rb を開き、以下のように編集してください。

class CreatePosts < ActiveRecord::Migration[7.0]
  def change
    create_table :posts do |t|
      t.string :title, null: false # タイトルカラム
      t.string :content, null: false # 本文カラム
      t.references :user, foreign_key: true, null: false # 外部キー (User)
      t.timestamps
    end
  end
end

Post はユーザーが作るものですので、必ずいずれかの User に紐づきます。
そのため、User を外部キーとして設定しています。

後ほど、Post モデルに User との紐付けを定義することで、Post に紐づく User、そして User に紐づく Post 情報を取得できるようにします。

マイグレーション実行

では、先ほど編集したマイグレーションファイルの内容をデータベースに反映していきましょう。
まずは development からです。

$ bundle exec rails db:migrate
== 20220727120112 CreatePosts: migrating ======================================
-- create_table(:posts)
   -> 0.0015s
== 20220727120112 CreatePosts: migrated (0.0015s) =============================

同じく test の方もマイグレーションしましょう。

$ RAILS_ENV=test bundle exec rails db:migrate
== 20220727120112 CreatePosts: migrating ======================================
-- create_table(:posts)
   -> 0.0007s
== 20220727120112 CreatePosts: migrated (0.0008s) =============================

エラーなく、マイグレーションが完了することを確認してください。

テスト作成

では、テスト駆動開発スタイルに従い、あるべき仕様を先にテストで定義しましょう。

以下のような動作を仕様として定義し、それぞれテストを追加していきます。

  • Post モデル
    • バリデーションの検証
    • 正常系
      • 正しいパラメータを渡せば
    • 異常系
      • title が空の場合は無効
      • title が 100 文字を超える場合は無効
      • content が空の場合は無効
      • content が 1000 文字を超える場合は無効
      • user_idが空の場合
    • Post が持つ情報の検証
    • 作成した Post が title を持つこと
    • 作成した Post が content を持つこと
    • 作成した Post から紐づく User 情報を取得できること
  • User モデル
    • 紐づく Post 情報を取得できること

では、テストを作成していきます。

FactoryBot の準備

Post モデルに関するテストをスムーズに作成するため、FactoryBot を用いてテスト内で Post を準備しやすくしておきます。
rails g コマンドで作成された spec/factories/post.rb を以下のように編集してください。

spec/factories/post.rb

FactoryBot.define do
  factory :post do
    title { 'タイトル1' }
    content { '本文1' }

    association :user, factory: :user
  end
end

association :user, factory: :user の1行により、同時に Post に紐づく User も作成できるように設定していることに注意してください。

これで、RSpec ファイル内で create(:post) という1行だけで Post を作成できるようになりました。
次は、実際に Post モデルのテストを準備しましょう。

Post モデルのテスト

spec/models/post_spec.rb

require 'rails_helper'

describe Post do
  before { @user = create(:user) } # 事前にユーザーを作成

  let(:title) { 'テストタイトル' }
  let(:content) { 'テスト本文' }
  let(:user_id) { @user.id } # 作成したユーザーのIDを外部キーに設定

  describe 'バリデーションの検証' do
    let(:post) { Post.new(title: title, content: content, user_id: user_id) }

    context '正常系' do
      it '有効である' do
        expect(post.valid?).to be(true)
      end
    end

    context '異常系' do
      context 'titleが空の場合' do
        let(:title) { nil }
        it '無効である' do
          expect(post.valid?).to be(false)
          expect(post.errors[:title]).to include('が入力されていません。')
        end
      end

      context 'titleが100文字を超える場合' do
        let(:title) { 'あ' * 101 }
        it '無効である' do
          expect(post.valid?).to be(false)
        end
      end

      context 'contentが空の場合' do
        let(:content) { nil }
        it '無効である' do
          expect(post.valid?).to be(false)
          expect(post.errors[:content]).to include('が入力されていません。')
        end
      end

      context 'contentが1000文字を超える場合' do
        let(:content) { 'あ' * 1001 }
        it '無効である' do
          expect(post.valid?).to be(false)
        end
      end

      context 'user_idが空の場合' do
        let(:user_id) { nil }
        it '無効である' do
          expect(post.valid?).to be(false)
          expect(post.errors[:user]).to include('が入力されていません。')
        end
      end
    end
  end

  describe 'Postが持つ情報の検証' do
    before { create(:post, title: title, content: content, user_id: user_id) } # Post を作成

    subject { described_class.first }

    it 'Postの属性値を返す' do
      expect(subject.title).to eq('テストタイトル')
      expect(subject.content).to eq('テスト本文')
      expect(subject.user_id).to eq(@user.id)
    end
  end
end

以上が Post モデルのテストです。
バリデーションのエラーメッセージは日本語ですので、後ほど翻訳ファイル (config/locales/devise.views.ja.yml) も修正する必要があります。

User モデルのテスト

次は User モデルに紐づく Post を取得できる仕様を満たすテストを追加します。

User モデルのテストは作成済みですので、その中にテストを追加・修正してあげます。

spec/models/user_spec.rb

...
  describe '.first' do
    before do
      @user = create(:user, nickname: nickname, email: email) # 修正
      @post = create(:post, title: 'タイトル', content: '本文', user_id: @user.id) # 修正
    end

    subject { described_class.first }

    it '事前に作成した通りのUserを返す' do
      expect(subject.nickname).to eq('テスト太郎')
      expect(subject.email).to eq('test@example.com')
    end

    ######## ここから追加 #######
    it '紐づくPostの情報を取得できる' do
      expect(subject.posts.size).to eq(1)
      expect(subject.posts.first.title).to eq('タイトル')
      expect(subject.posts.first.content).to eq('本文')
      expect(subject.posts.first.user_id).to eq(@user.id)
    end
    ######## ここまで追加 #######
  end
...

before ブロックの中で、User に紐づく Post を作成しています。

また、User は複数の Post を持つ場合があるので
User.post ではなく User.posts と、複数の Post を取得することに注意してください。

モデルの修正

では、テストが通るように Post モデルと User モデルを修正していきましょう.

Post モデルの修正

Post モデルを定義するファイルを以下のように修正します。

app/models/post.rb

class Post < ApplicationRecord
  belongs_to :user

  validates :title, presence: true, length: { maximum: 100 }
  validates :content, presence: true, length: { maximum: 1000 }
end

belongs_to により、Post に対して単一の User を紐付けています。

User モデルの修正

次に User モデルを定義するファイルを以下のように修正します。

app/models/user.rb

class User < ApplicationRecord
  has_many :posts # 追加

  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  validates :nickname, presence: true, length: { maximum: 20 }
end

has_many により、User に対して複数の Post を紐付けています。

これでモデル自体の修正は完了です。

翻訳ファイル修正

先ほどテスト修正時に伝えた通り、バリデーション時のエラー内容に対する日本語訳を定義する必要があります。

翻訳ファイル config/locales/devise.views.ja.yml を開き、ja.activerecord.errors.models.post配下の日本語訳を定義します。

ja:
  activerecord:
    errors:
      models:
        user:
          attributes:
            email:
              taken: "は既に使用されています。"
              blank: "が入力されていません。"
              invalid: "は有効でありません。"
            nickname:
              blank: "が入力されていません。"
              too_long: "は%{count}文字以下に設定して下さい。"
            password:
              blank: "が入力されていません。"
              too_short: "は%{count}文字以上に設定して下さい。"
              too_long: "は%{count}文字以下に設定して下さい。"
              invalid: "は有効でありません。"
            password_confirmation:
              confirmation: "が一致していません。"
        post: # ここから追加
          attributes:
            title:
              blank: "が入力されていません。"
              too_long: "は%{count}文字以下に設定して下さい。"
            content:
              blank: "が入力されていません。"
              too_long: "は%{count}文字以下に設定して下さい。"
            user:
              required: "が入力されていません。"
...

何かモデルを増やすたびに、このように user と同じ階層レベルで各属性のバリデーションメッセージを定義してあげる必要があります。

テスト実行

これで Post モデル、User モデル、そして翻訳ファイルの修正が完了したので仕様を満たしたはずです。
すべてのテストを実行し、コケるテストがないことを確認しましょう。

$ bin/rspec
...
Finished in 10.46 seconds (files took 0.49612 seconds to load)
37 examples, 0 failures

これから学習ログを管理していく準備が完了しました。

ここまでの変更をコミットしておきましょう。

$ git add .
$ git commit -m "Postモデルを作成"
$ git push

宿題

アソシエーション

Post と User 間の関連付けには「アソシエーション」という仕組みを利用しています。

Post のマイグレーションファイル内で user に foreign_key: true を含む1行を設定していましたが、
これがアソシエーションを利用することの宣言となります。

モデル間(テーブル間)の結びつけは中級者でも混乱しやすい分野ですので、整理して理解しておきましょう。

外部キーを含む FactoryBot

Post の Factory を定義した際、association を設定することで Post に紐づく User も同時に準備できるようになっていました。
余裕があれば、association の仕組みについても把握しておきましょう。

学習ログ投稿機能作成

今回は、学習ログを投稿するページと機能を作成していきましょう。

Controller 作成

rails gコマンドを使用し、Controller ファイル一式を作成します。
まず「投稿」機能を作成するため、new アクションは追加しておきます。

$ bundle exec rails g controller posts new
      create  app/controllers/posts_controller.rb
       route  get 'posts/new'
      invoke  tailwindcss
      create    app/views/posts
      create    app/views/posts/new.html.erb
      invoke  rspec
      create    spec/requests/posts_spec.rb

基本的なファイルは一式で作成されたので、それぞれ編集していきます。

なお、学習ログ関連機能を作る上ではあくまで実装の流れを理解することを優先していただくため、
テストではなく先に実装から作っていくことにします。
(もちろん、テスト駆動開発に慣れるためにテストから先に書いていただいても問題ありません)

実装

ルーティング

rails g実行により追加されたルーティング get 'posts/new' は削除し、
recourcesを使用したルーティングに変更します。

投稿機能では newcreateアクションが必要なので、それぞれを許可するルーティングを設定しましょう。

config/routes.rb

Rails.application.routes.draw do
  # get 'posts/new' # この1行を削除
  devise_for :users
  root 'home#top'

  resources :posts, only: [:new, :create] # 追加
end

この時点で、開発用サーバを起動 (bin/dev)して http://localhost:3000/posts/new へアクセスすると デフォルトのnew.html.erb の中身が表示されます。

image

次は実際に投稿機能を修正していきましょう。

Controller (#new, #create)

PostsController に newcreate アクションを用意してあげます。

app/controllers/posts_controller.rb

class PostsController < ApplicationController
  before_action :authenticate_user!

  def new
    @post = Post.new
  end

  def create
    @post = Post.new(post_params)
    @post.user_id = current_user.id # ログインユーザのIDを代入
    if @post.save
      flash[:notice] = '投稿しました'
      redirect_to root_path # 一時的にトップページへリダイレクト(要修正)
    else
      flash[:alert] = '投稿に失敗しました'
      render :new
    end
  end

  private

  def post_params
    params.require(:post).permit(:title, :content)
  end
end

中身としては基本的なものですが、簡単に解説しておきます。

  1. まず before_action でログインしているかどうかを判断する。
    1. ログインしていればそのまま new アクションへ進む。
    2. ログインしていなければログインページへリダイレクトする。
  2. new アクションでは空の Post モデルオブジェクトを用意し、view から渡されたパラメータを受け付ける。
  3. そのユーザー ID はログインしているユーザーの ID をセットする。
  4. Post の保存に成功 or 失敗で処理を分岐する。
    1. 成功すれば、成功時のフラッシュメッセージとともにトップページへリダイレクトする。
    2. 投稿の保存に失敗すれば、失敗時のフラッシュメッセージとともに投稿画面を再表示させる。

最初にログインユーザーかどうかで処理を分岐させていることがポイントです。
before_action :authenticate_user! も、devise を導入していると使える便利なヘルパーメソッドの一つです。

なお、Post の保存に成功した時、本来は学習ログの一覧画面へリダイレクトさせますが、
まだそのページを用意していないため、一時的にトップページへリダイレクトさせています。

次は投稿画面を用意します。

View

new アクション用の view も rails g コマンド実行時に生成されていますので、中身を修正します。
TailwindCSS を用いてデザインを調整した結果がこちらです。

app/views/posts/new.html.erb

<%= form_with model: @post, class: "space-y-6 w-3/4 max-w-lg" do |f| %>
  <label class="block text-xl font-bold text-gray-700">学習ログ投稿</label>
  <div class="mt-1">
    <label class="text-gray-700 text-lg">
      タイトル
    </label>
    <%= f.text_field :title, class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 mt-1 block w-full sm:text-sm placeholder-gray-400 border border-gray-300 rounded-md", placeholder: "Railsチュートリアル1章を完了" %>
  </div>
  <div class="mt-1">
    <label class="text-gray-700 text-lg">
      本文
    </label>
    <%= f.text_area :content, rows: "5", class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 mt-1 block w-full sm:text-sm placeholder-gray-400 border border-gray-300 rounded-md", placeholder: "環境構築を無事に終えることができた!" %>
  </div>
  <p class="mt-2 text-sm text-gray-500">
    学習したこと、開発したことを記録しましょう。<br>
    参考にしたサイトがあればURLを書いておくことをオススメします。
  </p>
  <div class="px-4 py-3 text-right sm:px-6">
    <%= f.submit "ログを記録", class: "inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
  </div>
<% end %>

form_with でControllerから渡されたインスタンス変数 @post とフォームを結びつけています。

また、デザインとしてはこのような見た目になっています。

image

翻訳ファイル修正

今回追加した処理に関し、devise の翻訳が必要となる箇所があるので翻訳ファイルに対訳を追加しましょう。

先ほど、学習ログの投稿時にはログインを必要とするよう、PostsController で設定しましたね。

app/controllers/posts_controller.rb (再掲)

class PostsController < ApplicationController
  before_action :authenticate_user!
  ...

これにより、ログイン前に投稿画面にアクセスするとログインを促すフラッシュメッセージが devise の仕組みにより表示されるのですが、
現在はその対訳が存在しないためエラーメッセージが表示されます。

image

そのため、表示されている通りの階層で、翻訳ファイル config/locales/devise.views.ja.ymlに対訳を追記してあげましょう。

...
  devise:
    failure:
      invalid: "%{authentication_keys}またはパスワードが違います。"
      user:
        unauthenticated: "ログインしてください。" # 追加
      ...

これにより、先ほどのエラーメッセージが「ログインしてください。」という日本語のメッセージに変わります。

image

ナビゲーションバーにリンク追加

最後に、ナビゲーションバーに投稿ページへのリンクを追加してあげます。
投稿はログイン状態でしか使えないため、ログイン時にのみ表示されるリンクとして追加します。

app/views/shared/_navbar.html.erb

...
        <% if current_user %>
          <%# ここから追加 %>
          <li>
            <%= link_to "ログ投稿", new_post_path, class: "block py-2 pr-4 pl-3 text-gray-400 hover:text-white border-b border-gray-700 hover:bg-gray-700 md:hover:bg-transparent md:border-0 md:hover:text-blue-white md:p-0" %>
          </li>
          <%# ここまで追加 %>
          <li>
            <%= button_to "ログアウト", destroy_user_session_path, class: "block py-2 pr-4 pl-3 text-gray-400 hover:text-white border-b border-gray-700 hover:bg-gray-700 md:hover:bg-transparent md:border-0 md:hover:text-blue-white md:p-0", method: :delete %>
          </li>
        <% else %>
        ...

動作確認

では、各種動作を画面で確認していきましょう。

初期表示

開発用サーバを起動し、http://localhost:3000/posts/newにアクセスしてください。
フォームが表示されていれば OK です。

image
投稿成功時

適当なタイトル、本文を入れて「ログを記録」ボタンを選択すると、成功時のフラッシュメッセージとともにトップページへリダイレクトします。

image
投稿失敗時

本文(またはタイトル)を空欄にして「ログを記録」ボタンを選択すると、失敗時のフラッシュメッセージとともに投稿画面が再表示されます。
この時、入力していた内容 (ここではタイトル) は残ったまま再表示されていることに注意してください。

image
ログアウト時

ログアウトしてからhttp://localhost:3000/posts/new に直接アクセスします。
先ほど確認した通り、ログインを促すメッセージと共にログイン画面へリダイレクトします。

image

ここまで画面で確認できれば OK です。

ナビゲーションバー(ログイン時)

ログイン時は「ログ投稿」というリンクが表示されていることを確認します。

image
ナビゲーションバー(ログアウト時)

ログアウト時は「ログ投稿」というリンクが表示されていないことを確認します。

image

テスト

さて、実装は完了したので上記の動作を保証するテストを書いていきましょう。

繰り返しとなりますが、今回は理解のしやすさからテストではなく実装を先に行っています。
そのため動作確認は先ほど終わっており、もはやテストを書く意味がないのでは?と思うかもしれません。
しかし、今後さまざまな変更を加える際、動作を保証するテストがないと変更をするのが不安になりますし、改めて動作確認をするのは手間です。
そのため、後追いでも構わないので必ずテストを書く習慣はつけるようにしましょう。

Request Spec

まずは、新しく作った投稿ページへのアクセスを確認する Request Spec を追加していきます。
Request Spec のテストファイル自体は rails g コマンド実行時に生成されているので、中身を編集するだけで OK です。

spec/requests/posts_spec.rb

require 'rails_helper'

RSpec.describe 'Posts', type: :request do
  before { @user = create(:user) } # 各テストで使用できるユーザーを作成

  describe 'GET /posts/new' do
    context 'ログインしていない場合' do
      it 'HTTPステータス302を返す' do
        get '/posts/new'
        expect(response).to have_http_status(302)
      end

      it 'ログインページにリダイレクトされる' do
        get '/posts/new'
        expect(response).to redirect_to '/users/sign_in'
      end
    end

    context 'ログインしている場合' do
      before { sign_in @user }
      it 'HTTPステータス200を返す' do
        get '/posts/new'
        expect(response).to have_http_status(200)
      end

      it 'ログインページにリダイレクトされない' do
        get '/posts/new'
        expect(response).not_to redirect_to '/users/sign_in'
      end
    end
  end
end

ログイン状態によってリダイレクトされる処理を含め、アクセス性を確認するテストを作成しました。
ここで一旦、テストを実行してみてください。

$ bundle exec rspec spec/requests/posts_spec.rb
...
  1) Posts GET /posts/new ログインしている場合 HTTPステータス200を返す
     Failure/Error: before { sign_in @user }

     NoMethodError:
       undefined method `sign_in' for #<RSpec::ExampleGroups::Posts::GETPostsNew::Nested_2 "HTTPステータス200を返す" (./spec/requests/posts_spec.rb:21)>
     # ./spec/requests/posts_spec.rb:20:in `block (4 levels) in <top (required)>'
...

RSpec でヘルパーメソッド sign_in を使えないというエラーが出て、コケました。

これは、以前 rails_helper.rbDevise::Test::IntegrationHelpers を読み込む設定を追加した際、
System Spec でしか使わないという設定をしていたためです。

今回、Request Spec でも sign_in メソッドが必要となったため、以下のように1行を追記してください。

spec/rails_helper.rb

  config.include Devise::Test::IntegrationHelpers, type: :system
  config.include Devise::Test::IntegrationHelpers, type: :request # 追加

もう一度テストを実行しましょう。
今度は通るはずです。

$ bundle exec rspec spec/requests/posts_spec.rb

Posts
  GET /posts/new
    ログインしていない場合
      HTTPステータス302を返す
      ログインページにリダイレクトされる
    ログインしている場合
      HTTPステータス200を返す
      ログインページにリダイレクトされない

Finished in 0.20681 seconds (files took 1.33 seconds to load)
4 examples, 0 failures

これで Request Spec は完了です。
次は System Spec を作成してあげましょう。

System Spec

System Spec は自動では作られていないので、まずはファイル自体を作成します。

$ touch spec/system/posts_spec.rb

作った System Spec の中身は以下のようにします。
基本的にはブラウザで動作確認した時と同一の内容をテストしています。

spec/system/posts_spec.rb

require 'rails_helper'

describe 'Post', type: :system do
  before do
    driven_by :selenium_chrome_headless # ヘッドレスモードで実行
    @user = create(:user) # ログイン用ユーザー作成
  end

  # 投稿フォーム
  let(:title) { 'テストタイトル' }
  let(:content) { 'テスト本文' }

  describe 'ログ投稿機能の検証' do
    # ログ投稿を行う一連の操作を subject にまとめる
    subject do
      fill_in 'post_title', with: title
      fill_in 'post_content', with: content
      click_button 'ログを記録'
    end

    context 'ログインしていない場合' do
      before { visit '/posts/new' }
      it 'ログインページへリダイレクトする' do
        expect(current_path).to eq('/users/sign_in')
        expect(page).to have_content('ログインしてください。')
      end
    end

    context 'ログインしている場合' do
      before do
        sign_in @user
        visit '/posts/new'
      end
      it 'ログインページへリダイレクトしない' do
        expect(current_path).not_to eq('/users/sign_in')
      end

      context 'パラメータが正常な場合' do
        it 'Postを作成できる' do
          expect { subject }.to change(Post, :count).by(1)
          expect(current_path).to eq('/')
          expect(page).to have_content('投稿しました')
        end
      end

      context 'パラメータが異常な場合' do
        let(:title) { nil }
        it 'Postを作成できない' do
          expect { subject }.not_to change(Post, :count)
          expect(page).to have_content('投稿に失敗しました')
        end
        it '入力していた内容は維持される' do
          subject
          expect(page).to have_field('post_content', with: content)
        end
      end
    end
  end
end

System Spec (Home)

今回、ナビゲーションバーにリンクを追加しました。
ナビゲーションバーのリンク表示のテストは Home の System Spec で行っていたため、テストを追加しておきます。

以下では、追記部分を中心に表記しているので注意してください。

spec/system/home_spec.rb

...
  describe 'ナビゲーションバーの検証' do
    context 'ログインしていない場合' do
      ...
      it 'ログ投稿リンクを表示しない' do # 追加
        expect(page).not_to have_link('ログ投稿', href: '/posts/new')
      end

      it 'ログアウトリンクは表示しない' do
        expect(page).not_to have_content('ログアウト')
      end
    ...
    context 'ログインしている場合' do
      ...
      it 'ログ投稿リンクを表示する' do # 追加
        expect(page).to have_link('ログ投稿', href: '/posts/new')
      end

      it 'ログアウトリンクを表示する' do
        expect(page).to have_content('ログアウト')
      end
...

最後に、すべてのテストに通ることを確認しておきます。

$ bin/rspec
...
Finished in 11.06 seconds (files took 0.38787 seconds to load)
48 examples, 0 failures

ここまでの変更をコミットしておきます。

$ git add .
$ git commit -m "学習ログ投稿機能を作成"
$ git push

宿題

authenticate_user!

PostsController の一部のアクションについてログインを必須とするよう、
devise のヘルパーメソッド authenticate_user! を設定しました。

このタイミングで devise で使えるヘルパーメソッドの全貌を理解しておきましょう。

学習ログ詳細機能作成

今回は、投稿した学習ログの詳細ページ作成していきましょう。

ルーティング

前回追加した recources :postsshow アクションも追加してあげましょう。

config/routes.rb

Rails.application.routes.draw do
  devise_for :users
  root 'home#top'

  resources :posts, only: [:new, :create, :show] # 追加
end

これで、/posts/:id というパスへアクセスすると学習ログの詳細ページのアクションへ飛ぶようになります。
ページを表示するためには show アクションが必要となるので Controller にアクションと、対応する View を追加していきましょう。

Controller (#show)

PostsController に show アクションを用意してあげます。
外部から呼び出すアクション (メソッド )なので、private の上に書くことに注意してください。

app/controllers/posts_controller.rb

class PostsController < ApplicationController
  before_action :authenticate_user!

  ...

  def show # 追加
    @post = Post.find_by(id: params[:id])
  end

  private

  def post_params
    params.require(:post).permit(:title, :content)
  end
end

/posts/:id:id 部分をパラメータとして受け取り、該当する Post を取得しています。

また、TechLog では投稿の閲覧についてはログインを必要としないため、authenticate_user!から show アクションは対象外とします。

class PostsController < ApplicationController
  before_action :authenticate_user!, except: [:show] # 修正
...

次は View を用意しましょう。

View

show アクションの view ファイルは用意されていないので、ファイルを作成してあげましょう。

$ touch app/views/posts/show.html.erb

中身は次のように編集してください。

app/views/posts/show.html.erb

<div class="space-y-6 w-3/4 max-w-lg">
  <label class="block text-xl font-bold text-gray-700">学習ログ詳細</label>
  <div class="items-center justify-center">
      <div tabindex="0" aria-label="card 1" class="focus:outline-none mb-7 bg-white p-6 shadow rounded">
        <div class="flex items-center border-b border-gray-200 pb-6">
          <div class="flex items-start justify-between w-full">
            <div class="pl-3">
              <p class="focus:outline-none text-lg font-medium leading-5 text-gray-800"><%= link_to @post.title, post_path(@post) %></p>
              <p class="focus:outline-none text-sm leading-normal pt-2 text-gray-500">by <%= @post.user.nickname %></p>
            </div>
          </div>
        </div>
        <div class="px-2">
          <p class="focus:outline-none text-sm leading-5 py-4 text-gray-600"><%= @post.content %></p>
        </div>
      </div>
  </div>
</div>

動作確認

では、実際に画面で確認していきましょう。

ログイン時

開発用サーバを起動し、投稿画面から何らかの投稿をします。
その後、http://localhost:3000/posts/xxへアクセスすると投稿の詳細画面が開きます。
(xxの部分は投稿回数に応じて数字を置き換えてください。わからなければ1を当てはめてください)

詳細ページへアクセスすると、このような見た目になっています。

image

ログアウト時

先ほどの投稿詳細画面の URL を一旦コピーしてからログアウトします。
その後、もう一度同じ URL にアクセスしましょう。
投稿画面とは異なり、ログインページへリダイレクトされずにそのまま詳細画面が表示されます。

image

ここまで画面で確認できれば OK です。

ナビゲーションバー(ログイン時)

ログイン時は「ログ投稿」というリンクが表示されていることを確認します。

image

ナビゲーションバー(ログアウト時)

ログアウト時は「ログ投稿」というリンクが表示されていないことを確認します。

image

テスト

さて、実装は完了したので上記の動作を保証するテストを書いていきましょう。

Request Spec

まずは、新しく作った詳細ページへのアクセスを確認する Request Spec を追加していきます。
今回は事前に Post が一つ以上は存在している必要があるため、Post の FactoryBot を使っていることに注意しましょう。

spec/requests/posts_spec.rb

require 'rails_helper'

RSpec.describe 'Posts', type: :request do
  before do # beforeブロックを修正
    @user = create(:user)
    @post = create(:post) # 追加
  end
...
  describe 'GET /posts/:id' do
    context 'ログインしていない場合' do
      it 'HTTPステータス200を返す' do
        get "/posts/#{@post.id}"
        expect(response).to have_http_status '200'
      end
    end
    context 'ログインしている場合' do
      it 'HTTPステータス200を返す' do
        sign_in @user
        get "/posts/#{@post.id}"
        expect(response).to have_http_status '200'
      end
    end
  end

Request Spec の全文は以下のようになります。

spec/requests/posts_spec.rb

require 'rails_helper'

RSpec.describe 'Posts', type: :request do
  before do
    @user = create(:user) # 各テストで使用できるユーザーを作成
    @post = create(:post) # 閲覧用の Post を作成
  end

  describe 'GET /posts/new' do
    context 'ログインしていない場合' do
      it 'HTTPステータス302を返す' do
        get '/posts/new'
        expect(response).to have_http_status(302)
      end

      it 'ログインページにリダイレクトされる' do
        get '/posts/new'
        expect(response).to redirect_to '/users/sign_in'
      end
    end

    context 'ログインしている場合' do
      before { sign_in @user }
      it 'HTTPステータス200を返す' do
        get '/posts/new'
        expect(response).to have_http_status(200)
      end

      it 'ログインページにリダイレクトされない' do
        get '/posts/new'
        expect(response).not_to redirect_to '/users/sign_in'
      end
    end
  end

  describe 'GET /posts/:id' do
    context 'ログインしていない場合' do
      it 'HTTPステータス200を返す' do
        get "/posts/#{@post.id}"
        expect(response).to have_http_status '200'
      end
    end
    context 'ログインしている場合' do
      it 'HTTPステータス200を返す' do
        sign_in @user
        get "/posts/#{@post.id}"
        expect(response).to have_http_status '200'
      end
    end
  end
end

ログイン状態によらずリダイレクトされずアクセスできることを含め、アクセス性を確認するテストを作成しました。
テストを実行してみてください。
すべて成功すれば OK です。

$ bin/rspec spec/requests/posts_spec.rb
DEBUGGER: Attaching after process 8205 fork to child process 10953
Running via Spring preloader in process 10953

Posts
  GET /posts/new
    ログインしていない場合
      HTTPステータス302を返す
      ログインページにリダイレクトされる
    ログインしている場合
      HTTPステータス200を返す
      ログインページにリダイレクトされない
  GET /posts/:id
    ログインしていない場合
      HTTPステータス200を返す
    ログインしている場合
      HTTPステータス200を返す

Finished in 0.51404 seconds (files took 0.70362 seconds to load)
6 examples, 0 failures

これで Request Spec は完了です。
次は System Spec を作成してあげましょう。

System Spec

こちらも Factory Bot を用いて事前に Post を1つ作成してから、詳細ページの表示内容を確認するテストにします。
System Spec に以下のテストを追記します。

spec/system/posts_spec.rb

require 'rails_helper'

describe 'Post', type: :system do
  before do
    driven_by :selenium_chrome_headless # ヘッドレスモードで実行
    @user = create(:user) # ログイン用ユーザー作成
    @post = create(:post, title: 'RSpec学習完了', content: 'System Specを作成した', user_id: @user.id) # 追加
  end
  ...

  ####### ここから追加 #######
  describe 'ログ詳細機能の検証' do
    before { visit "/posts/#{@post.id}" }

    it 'Postの詳細が表示される' do
      expect(page).to have_content('RSpec学習完了')
      expect(page).to have_content('System Specを作成した')
      expect(page).to have_content(@user.nickname)
    end
  end
end

この時点での System Spec の全文は以下の通りです。

spec/system/posts_spec.rb

require 'rails_helper'

describe 'Post', type: :system do
  before do
    driven_by :selenium_chrome_headless # ヘッドレスモードで実行
    @user = create(:user) # ログイン用ユーザー作成
    @post = create(:post, title: 'RSpec学習完了', content: 'System Specを作成した', user_id: @user.id)
  end

  # 投稿フォーム
  let(:title) { 'テストタイトル' }
  let(:content) { 'テスト本文' }

  describe 'ログ投稿機能の検証' do
    # ログ投稿を行う一連の操作を subject にまとめる
    subject do
      fill_in 'post_title', with: title
      fill_in 'post_content', with: content
      click_button 'ログを記録'
    end

    context 'ログインしていない場合' do
      before { visit '/posts/new' }
      it 'ログインページへリダイレクトする' do
        expect(current_path).to eq('/users/sign_in')
        expect(page).to have_content('ログインしてください。')
      end
    end

    context 'ログインしている場合' do
      before do
        sign_in @user
        visit '/posts/new'
      end
      it 'ログインページへリダイレクトしない' do
        expect(current_path).not_to eq('/users/sign_in')
      end

      context 'パラメータが正常な場合' do
        it 'Postを作成できる' do
          expect { subject }.to change(Post, :count).by(1)
          expect(current_path).to eq('/')
          expect(page).to have_content('投稿しました')
        end
      end

      context 'パラメータが異常な場合' do
        let(:title) { nil }
        it 'Postを作成できない' do
          expect { subject }.not_to change(Post, :count)
          expect(page).to have_content('投稿に失敗しました')
        end
        it '入力していた内容は維持される' do
          subject
          expect(page).to have_field('post_content', with: content)
        end
      end
    end
  end

  describe 'ログ詳細機能の検証' do
    before { visit "/posts/#{@post.id}" }

    it 'Postの詳細が表示される' do
      expect(page).to have_content('RSpec学習完了')
      expect(page).to have_content('System Specを作成した')
      expect(page).to have_content(@user.nickname)
    end
  end
end

テスト実行

最後に、すべてのテストに通ることを確認しておきます。

$ bin/rspec
...
Finished in 12 seconds (files took 0.69987 seconds to load)
51 examples, 0 failures

ここまでの変更をコミットしておきます。

$ git add .
$ git commit -m "学習ログ詳細機能を作成"
$ git push

学習ログ一覧機能作成

今回は、投稿した学習ログの一覧ページ作成していきましょう。

ルーティング

前回追加した recources :postsindex アクションも追加してあげましょう。

config/routes.rb

Rails.application.routes.draw do
  devise_for :users
  root 'home#top'

  resources :posts, only: [:new, :create, :show, :index] # 追加
end

これで、/posts というパスへアクセスすると学習ログの一覧ページのアクションへ飛ぶようになります。
ページを表示するためには index アクションが必要となるので Controller にアクションと、対応する View を追加していきましょう。

Controller (#index)

PostsController に index アクションを用意してあげます。
外部から呼び出すアクション (メソッド )なので、private の上に書くことに注意してください。

app/controllers/posts_controller.rb

class PostsController < ApplicationController
  before_action :authenticate_user!, except: [:show]

  ...

  def index # 追加
    @posts = Post.limit(10).order(created_at: :desc)
  end

  private
...

また、TechLog では投稿の閲覧についてはログインを必要としないため、
show アクションと同じくauthenticate_user!から index アクションも対象外とします。

class PostsController < ApplicationController
  before_action :authenticate_user!, except: [:show, :index] # 修正
...

次は View を用意しましょう。

View

index アクションの view ファイルは用意されていないので、ファイルを作成してあげましょう。

$ touch app/views/posts/index.html.erb

中身は次のように編集してください。

app/views/posts/index.html.erb

<div class="space-y-6 w-3/4 max-w-lg">
  <label class="block text-xl font-bold text-gray-700">学習ログ一覧</label>
  <div class="items-center justify-center">
    <% @posts.each do |post| %>
      <div tabindex="0" aria-label="card 1" class="focus:outline-none mb-7 bg-white p-6 shadow rounded">
        <div class="flex items-center border-b border-gray-200 pb-6">
          <div class="flex items-start justify-between w-full">
            <div class="pl-3">
              <p class="focus:outline-none text-lg font-medium leading-5 text-gray-800"><%= link_to post.title, post_path(post) %></p>
              <p class="focus:outline-none text-sm leading-normal pt-2 text-gray-500">by <%= post.user.nickname %></p>
            </div>
          </div>
        </div>
        <div class="px-2">
          <p class="focus:outline-none text-sm leading-5 py-4 text-gray-600"><%= post.content %></p>
        </div>
      </div>
    <% end %>
  </div>
</div>

ナビゲーションバーにリンク追加

最後に、ナビゲーションバーに投稿一覧ページへのリンクを追加してあげます。
投稿一覧はログイン状態に関係なく閲覧できるため、ログイン状態を判定する if 文の外に書いてあげましょう。

app/views/shared/_navbar.html.erb

...
      <ul class="flex flex-col mt-4 md:flex-row md:space-x-8 md:mt-0 md:text-sm md:font-medium">
        <%# ここから追加 %>
        <li>
          <%= link_to "ログ一覧", posts_path, class: "block py-2 pr-4 pl-3 text-gray-400 hover:text-white border-b border-gray-700 hover:bg-gray-700 md:hover:bg-transparent md:border-0 md:hover:text-blue-white md:p-0" %>
        </li>
        <%# 追加ここまで %>
        <% if current_user %>
        ...

Controller (#new)

最後に、投稿した後のリダイレクト先を変えましょう。
現在は一時的にルートページへリダイレクトしていましたが、投稿一覧ページへリダイレクトするように変更します。

app/controllers/posts_controller.rb

  def create
    @post = Post.new(post_params)
    @post.user_id = current_user.id # ログインユーザのIDを代入
    if @post.save
      flash[:notice] = '投稿しました'
      redirect_to posts_path # 修正

System Spec もこれに合わせて修正しておきます。

spec/system/posts_spec.rb

...
  describe 'ログ投稿機能の検証' do
  ...
    context 'ログインしている場合' do
      before do
        sign_in @user
        visit '/posts/new'
      end
      it 'ログインページへリダイレクトしない' do
        expect(current_path).not_to eq('/users/sign_in')
      end
        context 'パラメータが正常な場合' do
        it 'Postを作成できる' do
          expect { subject }.to change(Post, :count).by(1)
          expect(current_path).to eq('/posts') # 修正

動作確認

では、実際に画面で確認していきましょう。

ログイン時

開発用サーバを起動し、投稿画面から何らかの投稿をします。
その後、http://localhost:3000/postsへアクセスすると投稿の一覧画面が開きます。

一覧ページへアクセスすると、このような見た目になっています。

ナビゲーションバーにもログ一覧のリンクが追加されていることを確認しておきます。

テスト

さて、実装は完了したので上記の動作を保証するテストを書いていきましょう。

Request Spec

まずは、新しく作った一覧ページへのアクセスを確認する Request Spec を追加していきます。

spec/requests/posts_spec.rb

  ...
  describe 'GET /posts' do
    context 'ログインしていない場合' do
      it 'HTTPステータス200を返す' do
        get '/posts'
        expect(response).to have_http_status '200'
      end
    end
    context 'ログインしている場合' do
      it 'HTTPステータス200を返す' do
        sign_in @user
        get '/posts'
        expect(response).to have_http_status '200'
      end
    end
  end
  ...

Request Spec の全文は以下のようになります。

spec/requests/posts_spec.rb

require 'rails_helper'

RSpec.describe 'Posts', type: :request do
  before do
    @user = create(:user) # 各テストで使用できるユーザーを作成
    @post = create(:post) # 閲覧用の Post を作成
  end

  describe 'GET /posts/new' do
    context 'ログインしていない場合' do
      it 'HTTPステータス302を返す' do
        get '/posts/new'
        expect(response).to have_http_status(302)
      end

      it 'ログインページにリダイレクトされる' do
        get '/posts/new'
        expect(response).to redirect_to '/users/sign_in'
      end
    end

    context 'ログインしている場合' do
      before { sign_in @user }
      it 'HTTPステータス200を返す' do
        get '/posts/new'
        expect(response).to have_http_status(200)
      end

      it 'ログインページにリダイレクトされない' do
        get '/posts/new'
        expect(response).not_to redirect_to '/users/sign_in'
      end
    end
  end

  describe 'GET /posts/:id' do
    context 'ログインしていない場合' do
      it 'HTTPステータス200を返す' do
        get "/posts/#{@post.id}"
        expect(response).to have_http_status '200'
      end
    end
    context 'ログインしている場合' do
      it 'HTTPステータス200を返す' do
        sign_in @user
        get "/posts/#{@post.id}"
        expect(response).to have_http_status '200'
      end
    end
  end

  describe 'GET /posts' do
    context 'ログインしていない場合' do
      it 'HTTPステータス200を返す' do
        get '/posts'
        expect(response).to have_http_status '200'
      end
    end
    context 'ログインしている場合' do
      it 'HTTPステータス200を返す' do
        sign_in @user
        get '/posts'
        expect(response).to have_http_status '200'
      end
    end
  end
end

これで Request Spec は完了です。
次は System Spec を作成してあげましょう。

System Spec

Factory Bot を用いて事前に Post を2つ作成してから、一覧ページの表示内容を確認するテストにします。
System Spec に以下のテストを追記します。

spec/system/posts_spec.rb

require 'rails_helper'

describe 'Post', type: :system do
  before do
    driven_by :selenium_chrome_headless # ヘッドレスモードで実行
    @user = create(:user) # ログイン用ユーザー作成
    @post = create(:post, title: 'RSpec学習完了', content: 'System Specを作成した', user_id: @user.id)
    @post2 = create(:post, title: 'RSpec学習完了 2', content: 'System Specを作成した 2', user_id: @user.id) # 追加
  end
  ...

  ####### ここから追加 #######
  describe 'ログ一覧機能の検証' do
    before { visit '/posts' }

    it '1件目のPostの詳細が表示される' do
      expect(page).to have_content('RSpec学習完了')
      expect(page).to have_content('System Specを作成した')
      expect(page).to have_content(@user.nickname)
    end

    it '2件目のPostの詳細が表示される' do
      expect(page).to have_content('RSpec学習完了 2')
      expect(page).to have_content('System Specを作成した 2')
      expect(page).to have_content(@user.nickname)
    end

    it '投稿タイトルをクリックすると詳細ページへ遷移する' do
      click_link 'RSpec学習完了'
      expect(current_path).to eq("/posts/#{@post.id}")
    end
  end
end

この時点での System Spec の全文は以下の通りです。

spec/system/posts_spec.rb

require 'rails_helper'

describe 'Post', type: :system do
  before do
    driven_by :selenium_chrome_headless # ヘッドレスモードで実行
    @user = create(:user) # ログイン用ユーザー作成
    @post = create(:post, title: 'RSpec学習完了', content: 'System Specを作成した', user_id: @user.id)
    @post2 = create(:post, title: 'RSpec学習完了 2', content: 'System Specを作成した 2', user_id: @user.id)
  end

  # 投稿フォーム
  let(:title) { 'テストタイトル' }
  let(:content) { 'テスト本文' }

  describe 'ログ投稿機能の検証' do
    # ログ投稿を行う一連の操作を subject にまとめる
    subject do
      fill_in 'post_title', with: title
      fill_in 'post_content', with: content
      click_button 'ログを記録'
    end

    context 'ログインしていない場合' do
      before { visit '/posts/new' }
      it 'ログインページへリダイレクトする' do
        expect(current_path).to eq('/users/sign_in')
        expect(page).to have_content('ログインしてください。')
      end
    end

    context 'ログインしている場合' do
      before do
        sign_in @user
        visit '/posts/new'
      end
      it 'ログインページへリダイレクトしない' do
        expect(current_path).not_to eq('/users/sign_in')
      end

      context 'パラメータが正常な場合' do
        it 'Postを作成できる' do
          expect { subject }.to change(Post, :count).by(1)
          expect(current_path).to eq('/posts')
          expect(page).to have_content('投稿しました')
        end
      end

      context 'パラメータが異常な場合' do
        let(:title) { nil }
        it 'Postを作成できない' do
          expect { subject }.not_to change(Post, :count)
          expect(page).to have_content('投稿に失敗しました')
        end
        it '入力していた内容は維持される' do
          subject
          expect(page).to have_field('post_content', with: content)
        end
      end
    end
  end

  describe 'ログ詳細機能の検証' do
    before { visit "/posts/#{@post.id}" }

    it 'Postの詳細が表示される' do
      expect(page).to have_content('RSpec学習完了')
      expect(page).to have_content('System Specを作成した')
      expect(page).to have_content(@user.nickname)
    end
  end

  describe 'ログ一覧機能の検証' do
    before { visit '/posts' }

    it '1件目のPostの詳細が表示される' do
      expect(page).to have_content('RSpec学習完了')
      expect(page).to have_content('System Specを作成した')
      expect(page).to have_content(@user.nickname)
    end

    it '2件目のPostの詳細が表示される' do
      expect(page).to have_content('RSpec学習完了 2')
      expect(page).to have_content('System Specを作成した 2')
      expect(page).to have_content(@user.nickname)
    end

    it '投稿タイトルをクリックすると詳細ページへ遷移する' do
      click_link 'RSpec学習完了'
      expect(current_path).to eq("/posts/#{@post.id}")
    end
  end
end

テスト実行

最後に、すべてのテストに通ることを確認しておきます。

$ bin/rspec
...
Finished in 12.21 seconds (files took 0.41113 seconds to load)
58 examples, 0 failures

ここまでの変更をコミットしておきます。

$ git add .
$ git commit -m "学習ログ一覧機能を作成"
$ git push

学習ログ削除機能作成

今回は、学習ログ関連機能の締めくくりとして削除機能を実装します。

ルーティング

PostsController の各アクションへのルーティングについては resources で指定していました。
許可するアクションとして destroyも追加してあげましょう。

config/routes.rb

resources :posts, only: [ :index, :new, :create, :show, :destroy] # 修正

Controller (#destroy)

次に Controller にアクションを追加します。

app/controllers/posts_controller.rb

def destroy
  @post = Post.find_by(id: params[:id])
  if @post.user == current_user
    @post.destroy
    flash[:notice] = '投稿が削除されました'
  end
  redirect_to posts_path
end

毎度おなじみですが、private よりも上に追加することに注意しましょう。

View

最後に、view ファイルを編集します。

削除機能については個別の view は用意せず、投稿の詳細ページの中に削除ボタンを設置します。
ただし、他の人の投稿は削除できないよう、Post のユーザーとログインユーザーが一致する場合にのみ削除ボタンを表示させます。

以上の要件を満たした view ファイルが以下の通りです。
部分的に追加しています。

app/views/posts/show.html.erb

<div class="space-y-6 w-3/4 max-w-lg">
  <label class="block text-xl font-bold text-gray-700">学習ログ詳細</label>
  <div class="items-center justify-center">
    <div tabindex="0" aria-label="card 1" class="focus:outline-none mb-7 bg-white p-6 shadow rounded">
      <div class="flex items-center border-b border-gray-200 pb-6">
        <div class="flex items-start justify-between w-full">
          <div class="pl-3">
            <p class="focus:outline-none text-lg font-medium leading-5 text-gray-800"><%= link_to @post.title, post_path(@post) %></p>
            <p class="focus:outline-none text-sm leading-normal pt-2 text-gray-500">by <%= @post.user.nickname %></p>
          </div>
          <%# ここから追加 %>
          <% if user_signed_in? %>
            <% if @post.user_id == current_user.id %>
              <%= button_to "削除", post_path(@post), method: :delete, class: "text-sm bg-transparent hover:bg-blue-500 text-blue-700 hover:text-white py-1 px-3 border border-blue-500 hover:border-transparent rounded" %>
            <% end %>
          <% end %>
        </div>
      </div>
      <div class="px-2">
        <p class="focus:outline-none text-sm leading-5 py-4 text-gray-600"><%= @post.content %></p>
      </div>
    </div>
  </div>
</div>

devise のヘルパーメソッド user_signed_in? を使っています。
これはログインしていれば trueを返すヘルパーメソッドです。

これがないと、ログインしていない時には current_user が nil になるため、current_user.id のところで NoMethodError を吐いてしまうので注意してください。

<% if user_signed_in? %> がない場合のエラー

スクリーンショット 2022-08-01 9 32 42

動作確認

それでは動作確認していきましょう。

削除機能は、投稿したユーザーかどうかで挙動が変わるため、両方のパターンで動作確認しておきます。

投稿したユーザーの場合

まずは投稿したユーザー本人の場合です。
適当なユーザーでログインし、1件学習ログを投稿した後にその投稿の詳細画面を開きます。

http://localhost:3000/posts/:id

(:idには投稿した Post の ID を当てはめてください)

image

また、削除ボタンを押すとフラッシュメッセージ表示されると共に投稿が削除されつつ、
学習ログの一覧からも消えていることを確認してください。

image

投稿したユーザー以外の場合

次は投稿したユーザー以外の表示です。
別なユーザーを作ってログイン、またはログアウトし、学習ログ一覧から任意の投稿の詳細画面にアクセスしてください。
削除ボタンが表示されていなければ OK です。

image

テスト

それでは、削除機能に関するテストを追加しましょう。
先ほど動作確認した内容を System Spec に追加します。

  ...

  describe 'ログ削除機能の検証' do
    context '投稿したユーザーでログインしている場合' do
      before do
        sign_in @user
        visit "/posts/#{@post.id}"
      end

      it '削除ボタンを表示する' do
        expect(page).to have_button('削除')
      end

      it '削除ボタンをクリックすると削除できる' do
        expect do
          click_button '削除'
        end.to change(Post, :count).by(-1) # 削除ボタンをクリックするとPostが1つ減る

        # リダイレクト後の画面確認
        expect(current_path).to eq('/posts')
        expect(page).to have_content('投稿が削除されました') # フラッシュメッセージを表示
        expect(page).not_to have_link("/posts/#{@post.id}") # 削除した投稿(の詳細ページへのリンク)が存在しない
      end
    end

    context '投稿したユーザーでログインしていない場合' do
      it '削除ボタンを表示しない' do
        visit "/posts/#{@post.id}"
        expect(page).not_to have_button('削除')
      end

      it '直接リクエストを投げても削除されない' do
        visit "/posts/#{@post.id}"

        expect do
          delete post_path(@post) # 投稿データを削除するリクエストを送る
        end.not_to change(Post, :count)
      end
    end
  end
end

最後にすべてのテストを実行し、通ることを確認します。

$ bin/rspec
...
Finished in 13.55 seconds (files took 0.41183 seconds to load)
62 examples, 0 failures

ここまでの変更をコミットしておきましょう。

$ git add .
$ git commit -m "学習ログ削除機能を作成"
$ git push

宿題

user_signed_in?

今回、詳細ページで表示の切り替えをするために devise のヘルパーメソッドである user_signed_in? を使いました。

Controller, View のどちらでも使用できる便利なメソッドですので、改めて使い方はおさえておきましょう。

リファクタリング

さて、現在は '/' にアクセスすると HomeController の #top アクションが呼ばれ、仮のトップページが表示されていました。

スクリーンショット 2022-08-08 9 15 57

しかし TechLog ではトップページに学習ログ一覧を表示したいため、HomeController 関連をすべて消していきます。
ただし、ナビゲーションバーのテストは HomeController#top で検証していたため、そのあたりを投稿機能のテストに移行することに注意してください。

今回は HomeController に関し、以下の変更を行います。

  • テストの変更
    • Request Spec
    • posts のルーティング
    • home の Request Spec 削除
    • System Spec
    • ナビゲーションバーのテストを posts へ移行
    • posts#index のルーティング
    • home の System Spec 削除
  • ルーティングの変更
  • HomeController の削除
  • View の削除

今回は大きな変更であり、かつやることが明確であるためテストから先に変更してみましょう。

このように、変更する内容が明確であれば、テストから先に変更していくことができます。
テスト駆動開発では、このようにテストから先に変更していくことを「Red → Green → Refactoring」と呼びます。

Request Spec

まずは Request Spec の変更を行います。

posts#index のルーティング

現在、posts#index へのパスは /posts でした。
それがこれからはトップページのパス、つまり / となるのでテストを変更しておきます。

変更前: spec/requests/posts_spec.rb

  describe 'GET /posts' do
    context 'ログインしていない場合' do
      it 'HTTPステータス200を返す' do
        get '/posts'
        expect(response).to have_http_status '200'
      end
    end
    context 'ログインしている場合' do
      it 'HTTPステータス200を返す' do
        sign_in @user
        get '/posts'
        expect(response).to have_http_status '200'
      end
    end
  end

変更後: spec/requests/posts_spec.rb

  describe 'GET /' do # 修正
    context 'ログインしていない場合' do
      it 'HTTPステータス200を返す' do
        get '/' # 修正
        expect(response).to have_http_status '200'
      end
    end
    context 'ログインしている場合' do
      it 'HTTPステータス200を返す' do
        sign_in @user
        get '/' # 修正
        expect(response).to have_http_status '200'
      end
    end
  end

System Spec

次は System Spec に変更を行います。

ナビゲーションバーのテスト移行

home の System Spec には、ナビゲーションバーに関する以下のテストが含まれていました。

spec/system/home_spec.rb

...
  describe 'ナビゲーションバーの検証' do
    context 'ログインしていない場合' do
      before { visit '/' }

      it 'ログ一覧リンクを表示する' do
        expect(page).to have_link('ログ一覧', href: '/posts')
      end

      it 'ユーザー登録リンクを表示する' do
        expect(page).to have_link('ユーザー登録', href: '/users/sign_up')
      end

      it 'ログインリンクを表示する' do
        expect(page).to have_link('ログイン', href: '/users/sign_in')
      end

      it 'ログ投稿リンクを表示しない' do
        expect(page).not_to have_link('ログ投稿', href: '/posts/new')
      end

      it 'ログアウトリンクは表示しない' do
        expect(page).not_to have_content('ログアウト')
      end
    end

    context 'ログインしている場合' do
      before do
        user = create(:user) # ログイン用のユーザーを作成
        sign_in user # 作成したユーザーでログイン
        visit '/'
      end

      it 'ログ一覧リンクを表示する' do
        expect(page).to have_link('ログ一覧', href: '/posts')
      end

      it 'ユーザー登録リンクは表示しない' do
        expect(page).not_to have_link('ユーザー登録', href: '/users/sign_up')
      end

      it 'ログインリンクは表示しない' do
        expect(page).not_to have_link('ログイン', href: '/users/sign_in')
      end

      it 'ログ投稿リンクを表示する' do
        expect(page).to have_link('ログ投稿', href: '/posts/new')
      end

      it 'ログアウトリンクを表示する' do
        expect(page).to have_content('ログアウト')
      end

      it 'ログアウトリンクが機能する' do
        click_button 'ログアウト'

        # ログインしていない状態のリンク表示パターンになることを確認
        expect(page).to have_link('ユーザー登録', href: '/users/sign_up')
        expect(page).to have_link('ログイン', href: '/users/sign_in')
        expect(page).not_to have_button('ログアウト')
      end
    end
  end
end

ログイン状態に応じたナビゲーションバーの表示のテストを home の System Spec にまとめて書いていましたが、
中身は「ユーザー機能」と「ログ投稿機能」に関するテストが混在していました。

この機会に、それぞれ users の System Spec と posts の System Spec に移行しましょう。
home の System Spec をそれぞれの System Spec にコピペで貼り付け、それぞれの機能に関する箇所だけを残すようにすると移行が楽です。

spec/system/posts_spec.rb

...
  #### ここから追加 ####
  describe 'ナビゲーションバーの検証' do
    context 'ログインしていない場合' do
      before { visit '/' }

      it 'ログ一覧リンクを表示する' do
        expect(page).to have_link('ログ一覧', href: '/posts')
      end

      it 'ログ投稿リンクを表示しない' do
        expect(page).not_to have_link('ログ投稿', href: '/posts/new')
      end
    end

    context 'ログインしている場合' do
      before do
        user = create(:user) # ログイン用のユーザーを作成
        sign_in user # 作成したユーザーでログイン
        visit '/'
      end

      it 'ログ一覧リンクを表示する' do
        expect(page).to have_link('ログ一覧', href: '/posts')
      end

      it 'ログ投稿リンクを表示する' do
        expect(page).to have_link('ログ投稿', href: '/posts/new')
      end
    end
  end
  #### ここまで追加 ####

spec/system/users_spec.rb

...
  #### ここから追加 ####
  describe 'ナビゲーションバーの検証' do
    context 'ログインしていない場合' do
      before { visit '/' }

      it 'ユーザー登録リンクを表示する' do
        expect(page).to have_link('ユーザー登録', href: '/users/sign_up')
      end

      it 'ログインリンクを表示する' do
        expect(page).to have_link('ログイン', href: '/users/sign_in')
      end

      it 'ログアウトリンクは表示しない' do
        expect(page).not_to have_content('ログアウト')
      end
    end

    context 'ログインしている場合' do
      before do
        user = create(:user) # ログイン用のユーザーを作成
        sign_in user # 作成したユーザーでログイン
        visit '/'
      end

      it 'ユーザー登録リンクは表示しない' do
        expect(page).not_to have_link('ユーザー登録', href: '/users/sign_up')
      end

      it 'ログインリンクは表示しない' do
        expect(page).not_to have_link('ログイン', href: '/users/sign_in')
      end

      it 'ログアウトリンクを表示する' do
        expect(page).to have_content('ログアウト')
      end

      it 'ログアウトリンクが機能する' do
        click_button 'ログアウト'

        # ログインしていない状態のリンク表示パターンになることを確認
        expect(page).to have_link('ユーザー登録', href: '/users/sign_up')
        expect(page).to have_link('ログイン', href: '/users/sign_in')
        expect(page).not_to have_button('ログアウト')
      end
    end
  end
  #### ここまで追加 ####

posts#index のルーティング

Post の System Spec についてですが、投稿一覧のパスは /posts ではなく / になったので修正しておきましょう。

spec/system/posts_spec.rb

require 'rails_helper'

describe 'Post', type: :system do
...
  describe 'ログ投稿機能の検証' do
    ...
    context 'ログインしている場合' do
      ...
      context 'パラメータが正常な場合' do
        it 'Postを作成できる' do
          expect { subject }.to change(Post, :count).by(1)
          expect(current_path).to eq('/') # 修正
          ...
  describe 'ログ一覧機能の検証' do
    before { visit '/' } # 修正
    ...
  describe 'ログ削除機能の検証' do
    context '投稿したユーザーでログインしている場合' do
      ...
      it '削除ボタンをクリックすると削除できる' do
        ...
        # リダイレクト後の画面確認
        expect(current_path).to eq('/') # 修正
        ...
  describe 'ナビゲーションバーの検証' do
    context 'ログインしていない場合' do
      before { visit '/' }

      it 'ログ一覧リンクを表示する' do
        expect(page).to have_link('ログ一覧', href: '/') # 修正
      end
      ...
    context 'ログインしている場合' do
      ...
      it 'ログ一覧リンクを表示する' do
        expect(page).to have_link('ログ一覧', href: '/posts') # 修正
        ...

修正後の Post System Spec の全文は以下のようになります。

spec/system/posts_spec.rb

require 'rails_helper'

describe 'Post', type: :system do
  before do
    driven_by :selenium_chrome_headless # ヘッドレスモードで実行
    @user = create(:user) # ログイン用ユーザー作成
    @post = create(:post, title: 'RSpec学習完了', content: 'System Specを作成した', user_id: @user.id)
    @post2 = create(:post, title: 'RSpec学習完了 2', content: 'System Specを作成した 2', user_id: @user.id)
  end

  # 投稿フォーム
  let(:title) { 'テストタイトル' }
  let(:content) { 'テスト本文' }

  describe 'ログ投稿機能の検証' do
    # ログ投稿を行う一連の操作を subject にまとめる
    subject do
      fill_in 'post_title', with: title
      fill_in 'post_content', with: content
      click_button 'ログを記録'
    end

    context 'ログインしていない場合' do
      before { visit '/posts/new' }
      it 'ログインページへリダイレクトする' do
        expect(current_path).to eq('/users/sign_in')
        expect(page).to have_content('ログインしてください。')
      end
    end

    context 'ログインしている場合' do
      before do
        sign_in @user
        visit '/posts/new'
      end
      it 'ログインページへリダイレクトしない' do
        expect(current_path).not_to eq('/users/sign_in')
      end

      context 'パラメータが正常な場合' do
        it 'Postを作成できる' do
          expect { subject }.to change(Post, :count).by(1)
          expect(current_path).to eq('/')
          expect(page).to have_content('投稿しました')
        end
      end

      context 'パラメータが異常な場合' do
        let(:title) { nil }
        it 'Postを作成できない' do
          expect { subject }.not_to change(Post, :count)
          expect(page).to have_content('投稿に失敗しました')
        end
        it '入力していた内容は維持される' do
          subject
          expect(page).to have_field('post_content', with: content)
        end
      end
    end
  end

  describe 'ログ詳細機能の検証' do
    before { visit "/posts/#{@post.id}" }

    it 'Postの詳細が表示される' do
      expect(page).to have_content('RSpec学習完了')
      expect(page).to have_content('System Specを作成した')
      expect(page).to have_content(@user.nickname)
    end
  end

  describe 'ログ一覧機能の検証' do
    before { visit '/posts' }

    it '1件目のPostの詳細が表示される' do
      expect(page).to have_content('RSpec学習完了')
      expect(page).to have_content('System Specを作成した')
      expect(page).to have_content(@user.nickname)
    end

    it '2件目のPostの詳細が表示される' do
      expect(page).to have_content('RSpec学習完了 2')
      expect(page).to have_content('System Specを作成した 2')
      expect(page).to have_content(@user.nickname)
    end

    it '投稿タイトルをクリックすると詳細ページへ遷移する' do
      click_link 'RSpec学習完了'
      expect(current_path).to eq("/posts/#{@post.id}")
    end
  end

  describe 'ログ削除機能の検証' do
    context '投稿したユーザーでログインしている場合' do
      before do
        sign_in @user
        visit "/posts/#{@post.id}"
      end

      it '削除ボタンを表示する' do
        expect(page).to have_button('削除')
      end

      it '削除ボタンをクリックすると削除できる' do
        expect do
          click_button '削除'
        end.to change(Post, :count).by(-1) # 削除ボタンをクリックするとPostが1つ減る

        # リダイレクト後の画面確認
        expect(current_path).to eq('/')
        expect(page).to have_content('投稿が削除されました') # フラッシュメッセージを表示
        expect(page).not_to have_link("/posts/#{@post.id}") # 削除した投稿(の詳細ページへのリンク)が存在しない
      end
    end

    context '投稿したユーザーでログインしていない場合' do
      it '削除ボタンを表示しない' do
        visit "/posts/#{@post.id}"
        expect(page).not_to have_button('削除')
      end

      it '直接リクエストを投げても削除されない' do
        visit "/posts/#{@post.id}"

        expect do
          delete post_path(@post) # 投稿データを削除するリクエストを送る
        end.not_to change(Post, :count)
      end
    end
  end

  describe 'ナビゲーションバーの検証' do
    context 'ログインしていない場合' do
      before { visit '/' }

      it 'ログ一覧リンクを表示する' do
        expect(page).to have_link('ログ一覧', href: '/')
      end

      it 'ログ投稿リンクを表示しない' do
        expect(page).not_to have_link('ログ投稿', href: '/posts/new')
      end
    end

    context 'ログインしている場合' do
      before do
        user = create(:user) # ログイン用のユーザーを作成
        sign_in user # 作成したユーザーでログイン
        visit '/'
      end

      it 'ログ一覧リンクを表示する' do
        expect(page).to have_link('ログ一覧', href: '/')
      end

      it 'ログ投稿リンクを表示する' do
        expect(page).to have_link('ログ投稿', href: '/posts/new')
      end
    end
  end
end

ルーティングの変更

ではここから、テストが通るように変更を加えていきましょう。
まずはルーティングの設定からです。

root ('/') にアクセスした時に学習ログ一覧を表示させるため、ルーティング設定を変更します。
そのために、root 指定で呼ぶアクションを posts#index に変更します。

config/routes.rb

Rails.application.routes.draw do
  devise_for :users
  root 'posts#index' # 修正

  resources :posts, only: [:new, :create, :show, :destroy] # 修正
end

また、resources 内の :index 指定は不要になるので削除していることに注意してください。。

PostsController の変更

PostsController の create アクションと destroy アクションんでは、処理の成功時に投稿一覧へリダイレクトするようにしていました。
そのリダイレクト先の URL は posts_path と指定していましたので、ここを root_path に変更しておきます。

app/controllers/posts_controller.rb

  def create
    @post = Post.new(post_params)
    @post.user_id = current_user.id # ログインユーザのIDを代入
    if @post.save
      flash[:notice] = '投稿しました'
      redirect_to root_path # 修正
    else
      flash[:alert] = '投稿に失敗しました'
      render :new
    end
  end

  ...
  def destroy
    @post = Post.find_by(id: params[:id])
    if @post.user == current_user
      @post.destroy
      flash[:notice] = '投稿が削除されました'
    end
    redirect_to root_path # 修正
  end

ナビゲーションバー内リンクの変更

最後に、ナビゲーションバーの「ログ一覧」リンクのパスも変更します。
ここも posts_path で指定していたので root_path に置き換えてあげましょう。

(修正前): source/techlog/saigen/app/views/shared/_navbar.html.erb

...
<%= link_to "ログ一覧", posts_path, class: "block py-2 pr-4 pl-3 text-gray-400 hover:text-white border-b border-gray-700 hover:bg-gray-700 md:hover:bg-transparent md:border-0 md:hover:text-blue-white md:p-0" %>

(修正後): source/techlog/saigen/app/views/shared/_navbar.html.erb

...
<%= link_to "ログ一覧", root_path, class: "block py-2 pr-4 pl-3 text-gray-400 hover:text-white border-b border-gray-700 hover:bg-gray-700 md:hover:bg-transparent md:border-0 md:hover:text-blue-white md:p-0" %>

Home 関連のファイル削除

Home に関するファイルは不要になるので、すべて削除します。

まずはControllerからです。

$ rm app/controllers/home_controller.rb

エディタの操作で削除しても OK です。
ファイルが削除されていることを確認してください。

次はView です。
ディレクトリごと削除します。

$ rm -rf app/views/home

最後にテストです。
Request Specも、System Spec も不要になった削除します。

$ rm spec/requests/home_spec.rb
$ rm spec/system/home_spec.rb

テスト実行

変更もすべて完了したので、すべてのテストに通ることを確認します。

$ bin/rspec
Finished in 12.2 seconds (files took 0.28092 seconds to load)
60 examples, 0 failures

では今回の変更をコミットしておきましょう。

$ git add .
$ git commit -m "Home関連機能を削除"
$ git push

今回は変更点が多かったので大変かもしれませんが、テストが失敗することで修正漏れに気付けるようになっているので安心してください!

ここまでで実装は完了です。お疲れ様でした!

あわせて読みたい

よかったらシェアしてね!

この記事を書いた人

フルスタックWebエンジニアであり、プログラミングスクール講師でもあります。
プログラミングスクールのカリキュラム執筆にも携わっており、プログラミング教材に精通しています。
Rails / React / Next.js / Vue.js / AWS が得意技術。