バッチ処理の実装

今回は記事投稿アプリにバッチ処理の実装をしてみます。
最終的にはこんなことをやりたいです。

やりたいこと

記事の状態が「下書き」「公開」「公開待ち」と3種類あります。
例えば記事の公開日を1日後にすると記事の状態は「公開待ち」になります。
時間が経って公開日を過ぎたら自動で記事が公開されるようにしたいです。

必要なもの

・Rakeタスク
・cron
・whenever
これらを使って自動で記事が公開される仕組みを作っていきます。
具体的に言うと1時間毎に記事の公開日時が現在時刻を過ぎているか判定し、過ぎていれば記事の状態を「公開」に変更します。
このように一定の間隔で処理を行うことをバッチ処理と言います。

Rakeとは

Rakeとは、rubyで処理内容を定義できるビルドツールです。
このRakeが実行する処理内容をRakeタスクと呼び、Rakefileに定義します。

cronとは

cronとは、Unix系のOSに標準で備わっている仕組みです。
「この時間にこのプログラムを実行」「毎日のこの時間になったらこのプログラムの実行」という感じで定期的にコマンドを実行するためにメモリ場で常に命令を待機しているプロセス(=デーモンプロセス)です。

wheneverとは

cronの設定を、rubyの簡単な文法で扱えるようにしたライブラリです。

Rakeタスクファイルを作成

では進めていきましょう。
まずはRakeファイルを作成し、そこに実現したい処理を書いていきます。
記事の状態に関する処理なので名前はarticle_stateとしておきます。

rails g task article_state

こちらのコマンドを実行するとlib/tasks以下にarticle_state.rakeが作成されます。

namespace :article_state do

end

この中に処理を書いていきます。

namespace :article_state do
  desc '公開日時が過去の日付の「公開待ち」記事があれば、ステータスを「公開」に変更する' # desc = description(説明)
  task update_article_state: :environment do # environmentはDBとのやりとりが必要な際に記述します
     # ここに処理を書きます
  end
end

こんな感じです。
で、処理なんですが、

Article.where(state: :publish_wait).each do |article|
  if article.publishd_at <= Time.current
    article.state = 'published'
  end
end

のようにしてもいいのですが、モデルのscopeを使うとスッキリ書けるようなので使ってみます。

models/article.rb

enum state: { draft: 0, published: 1, publish_wait: 2 }

scope :past_published, -> { where('published_at <= ?', Time.current) }

現在時刻と比べて公開日が過去になっている記事を取得して、past_publishedという名前を付けています。
これを使って

namespace :article_state do
  desc '公開待ちの中で、公開日時が過去になっているものがあれば、ステータスを「公開」に変更されるようにする'
  task update_article_state: :environment do
    Article.publish_wait.past_published.find_each(&:published!)
  end
end

このようにしました。
publish_waitenumの定義により使えるようになったものでstateがpublish_waitの記事を取得します。
past_publishedは先ほど定義したscopeです。
これを連結して公開日が過去かつ公開待ちの記事を取得しています。

データの取得には、eachだとデータが大量にあった場合にメモリを圧迫するのでfind_eachを使っています。
こちらはデフォルトでは最大1000件のデータを取得して、処理が終わると次の1000件、という感じでメモリへの負荷が少ないです。

(&:published!)の部分はまだ理解できていないのですが、{ |article| article.published! }を省略した形のようです。
article.published!が記事のstateを公開に変更しているのは分かるのですがあとがちょっと分かりません。
学習用に、あるアプリの改修という形で実装しているため分からないところが出てきますがご容赦ください
参考になりそうなサイトだけ貼っておきます。
Ruby: アンパサンドとコロン`&:`記法について調べてみた|TechRacho(テックラッチョ)〜エンジニアの「?」を「!」に〜|BPS株式会社

gem wheneverの導入

Rakeタスクが出来たのでcronの設定をするのですが、まずはgemwheneverをインストールします。
Gemfileに

gem 'whenever', require: false

としてbundle installします。
require: falseとするのはこのGem自体がRailsアプリケーションに反映するものではなく、ターミナル(言わばOS)に反映させるものだから、だそうです。

bundle exec wheneverize .

このコマンドでconfig/schedule.rbというファイルが作成されるのでここにcronの設定を記述します。

# Rails.rootを使用するために必要
require File.expand_path(File.dirname(__FILE__) + '/environment')
# cronを実行する環境変数
rails_env = ENV['RAILS_ENV'] || :development
# cronを実行する環境変数をセット
set :environment, rails_env
# cronのログの吐き出し場所
set :output, "#{Rails.root}/log/cron.log"
# 1時間毎にrakeタスクを実行
every 1.hour do
  rake 'article_state:update_article_state'
end

cronに設定を反映させる

schedule.rbに設定の記述はしましたがまだ反映がされていません。

bundle exec whenever 

で設定にエラーがないかを確認し、

bundle exec whenever --update-crontab 

でcronに設定を反映します。

clone -l

で設定されているcronを確認できます。

最後に

難しかったですが使いこなせればできることの幅が広がりそうです。
間違いなどあればご指摘いただければと思います。
今回は以上です。ありがとうございました。

参考サイト

Railsでwheneverを使ってバッチを回す!(rakeタスク、cron) - Qiita

RakeタスクとCronを使って1時間ごとにタスクを実行する - WeBlog

Rails のコマンドラインツール - Railsガイド

library rake (Ruby 3.0.0 リファレンスマニュアル)

Railsのモデルのscopeを理解しよう - Qiita

【Rails】 | Pikawaka - ピカ1わかりやすいプログラミング用語サイト

GitHub - javan/whenever: Cron jobs in Ruby

assign_attributesメソッド

記事投稿アプリで公開日が過去の日付なら「公開」、未来の日付なら「公開待ち」と状態を変えるのにつまづいたので忘備録として残しておきます。
前提として記事の状態stateenumで定義されています。

# draft: 下書き, published: 公開, publish_wait: 公開待ち
enum state: { draft: 0, published: 1, publish_wait: 2 }

article_controller.rbのupdateアクションがこのようになっています。

before_action :set_article, only: %i[edit update destroy]

def update
  authorize(@article)

  if @article.update(article_params)
    flash[:notice] = '更新しました'
    redirect_to edit_article_path(@article.uuid)
  else
    render :edit
  end
end

def article_params
  params.require(:article).permit(
    :title, :description, :state, :published_at, :author_id
  )
end

def set_article
  @article = Article.find_by!(uuid: params[:uuid])
end

@article.update(article_params)で情報を受け取り更新処理しています。
しかし下記のように、状態を公開日付を未来の日付にして更新した場合は、状態は公開ではなく公開待ちになるようにしたいのですが、現状のupdateアクションでは公開日がまだ来ていないにも関わらず、公開されてしまいます。

Image from Gyazo Image from Gyazo

これは@article.update(article_params)updateメソッドでarticle_paramsを受け取ったときにDBにstateが公開という情報が保存されるからです。
ユーザーが、公開日を未来の日付、状態を公開で更新してしまうことは考えられるので対策が必要です。
ここでassign_attributesメソッドを使います。

assign_attributesメソッドとは

引数で指定したattributes(属性)を変更するメソッドです。
ただしDBへの保存はされないので別途saveメソッドで保存します。

これを踏まえ、このような流れにしたいです。
① assign_attributesメソッドで変更を受け取る
現在時刻が公開日を過ぎているか、いないかでstateを公開、公開待ち、と振り分ける
③ saveメソッドで保存

ではまずupdateアクションに①、③を書いていきます。

def update
  authorize(@article)

  @article.assign_attributes(article_params) # ①
  # ②
  if @article.save # ③
    flash[:notice] = '更新しました'
    redirect_to edit_article_path(@article.uuid)
  else
    render :edit
  end
end

こうなりました。
あとは②の処理をどのようにするかですが、FatControllerにならないようmodelにメソッドとして切り出しました。

models/article.rb

def publishable? # 現在時刻が公開日を過ぎていればtrueを返す
  Time.current >= published_at
end

def change_state
  return if draft? # draft?はstateがdraftかどうか判定する trueならreturnによりここで処理が終わる(nilが返る)
  self.state = if publishable? # stateがpublishedなら:published(公開)を返す
                 :published
               else # それ以外(publish_waitしか残ってないのでpublish_wait)なら:publish_wait(公開待ち)を返す
                 :publish_wait
               end
end

enumで定義しているのでdraft?のように直感的にstateの状態を確認できます。(draftならtrue、違えばfalseを返す)
あとはこれを②の位置に入れます。

def update
  authorize(@article)

  @article.assign_attributes(article_params) # ①
  @article.change_state # ②
  if @article.save # ③
    flash[:notice] = '更新しました'
    redirect_to edit_article_path(@article.uuid)
  else
    render :edit
  end
end

ではこれで再度確認してみます。
未来の日付で状態を公開にし

Image from Gyazo

更新すると

Image from Gyazo Image from Gyazo

無事、状態が公開待ちに変わりました。

最後に

updateメソッドとはassign_attributes + saveだったんですね。
間に何か処理を挟みたいときにassign_attributesを使うと良さそうです。
updateもupdate_attributesのalias(別名)なので
・update_attributes(DB更新あり)
・assign_attributes(DB更新なし)
のように並べてみると分かりやすいですね。
では今回は以上です。ありがとうございました。

参考サイト

まとめてオブジェクトの属性を変更したい時に便利!assign_attributesメソッド - その辺にいるWebエンジニアの備忘録

ActiveModel::AttributeAssignment

Active Recordのattributesの更新メソッド | 酒と涙とRubyとRailsと

ActiveRecord の attribute 更新方法まとめ - Qiita

エラーメモ【TypeError】

エラーの解決過程を記録しておきます。
記事投稿アプリで記事内容を未入力で投稿しようとすると以下のようなエラーが発生しました。

TypeError - no implicit conversion of nil into String:

TypeErrorとは

メソッドの引数に期待される型ではないオブジェクトや、期待される振る舞いを持たないオブジェクトが渡された時に発生します。

エラー文を翻訳

no implicit conversion of nil into Stringを翻訳すると

nilからStringへの暗黙の変換がない

となりました。
string型に変換する必要がありそうです。

該当箇所を確認

現在の該当箇所のコードは

  def build_body(controller)
    result = ''

    article_blocks.each do |article_block|
      result << if article_block.sentence?
                  sentence = article_block.blockable
                  sentence.body
                end
    end

    result
  end

こんな感じです。
簡単に言うとコンテンツがあれば<<でresultに追加する、というコードです。
コンテンツが存在すれば正常に処理され画面が表示されますが、コンテンツがないままだとエラーが発生する状態です。

string型のオブジェクトを取得したいのにnil型だから受け取れない、とのことなのでまずは実際に何型のオブジェクトか見てみます。

Image from Gyazo

sentence.body.classsentence.bodynil型だと分かりました。
これをstring型にできれば解決するはずです。

どうやって型を変えるか

初めに思いついたのはto_sメソッドでstring型に変換するやり方です。

Image from Gyazo

想定通り、string型になりました。
このやり方でもエラーは解決しましたが、||=を使って空文字を明示する書き方の方が良いそうです。

  def build_body(controller)
    result = ''

    article_blocks.each do |article_block|
      result << if article_block.sentence?
                  sentence = article_block.blockable
                  sentence.body ||= ''
                end
    end

    result
  end

このようにしてエラー解決できました。

||= は何をしているか

||=は自己代入やnilガードと呼ばれるそうです。
左辺がnilまたはfalseの場合は右辺が代入され、それ以外の場合は代入はされません。
sentence.bodyに「テスト」と入力して確認してみます。
入力されているのでnilにはならないはずです。

Image from Gyazo

nil?メソッドでfalseが返ってきたのでsentence.bodyはnilではないことがわかります。
nilではないので右辺の''(空の文字列)は代入されず「テスト」となっていますね。

書籍などで||=この書き方を見たり、nilガードという言葉自体は見たことありましたが、実際に検証して理解が深まりました。

最後に

||= これだけ見ると記号か暗号のようですが、hoge = 1のような変数に代入するコードに||という関所があって、hogeが空っぽの時は1さんに「うむ、入れてやろう」、hogeに何か入ってる時は「今hogeは満員だ。帰れ」と言ってるイメージで覚えました。知らんけど。
今回は以上です。ありがとうございました。

参考サイト

class TypeError (Ruby 3.0.0 リファレンスマニュアル)

Rubyで使われる記号の意味(正規表現の複雑な記号は除く) (Ruby 3.0.0 リファレンスマニュアル)

【Ruby】使いこなせると便利。||演算子のいろんな使い方 - Qiita

Rubyの自己代入(||=)とはこんな仕組みです - Qiita

【Ruby入門】nilのポイントまとめ(nil? empty? blank? present?) | 侍エンジニアブログ

Rubyのnilガードについて調べてみた - Qiita

パンくずリストを作ろう

今回はパンくずリストを作ってみたいと思います。

パンくずリストとは

自分が今サイトのどこにいるかわかりやすく表示したものです。
Image from Gyazo
こんなやつです。

アプリ立ち上げ

アプリの新規作成からやっていきます。

rails new gretel_app

名前はgretel_appとしておきます。
ターミナルでgretel_appに移動したら

rails g scaffold task title:string body:text

こんな感じでscaffoldして

rails db:migrate

マイグレートして

rails s

サーバーを起動します。
タスク一覧はこんな感じです。
Image from Gyazo

gem gretelの導入

gretelというgemで簡単にパンくずリストを作ることができます。
Gemfileに

gem 'gretel'

として

bundle install

さらに

rails g gretel:install

とすると、config/breadcrumb.rbが作成されます。
これで準備できました。

breadcrumb.rbに設定を記述

ではパンくずリストの設定をしていきます。

crumb :root do
  link "Home", root_path
end

# crumb :projects do
#   link "Projects", projects_path
# end

# crumb :project do |project|
#   link project.name, project_path(project)
#   parent :projects
# end

# crumb :project_issues do |project|
#   link "Issues", project_issues_path(project)
#   parent :project, project
# end

# crumb :issue do |issue|
#   link issue.title, issue_path(issue)
#   parent :project_issues, issue.project
# end

# If you want to split your breadcrumbs configuration over multiple files, you
# can create a folder named `config/breadcrumbs` and put your configuration
# files there. All *.rb files (e.g. `frontend.rb` or `products.rb`) in that
# folder are loaded and reloaded automatically when you change them, just like
# this file (`config/breadcrumbs.rb`).

こちらが先程のコマンドで作成されたファイルの中身です。

crumb :new_task do
  link 'New Task', new_task_path
  parent :root
end

これでタスクの新規作成ページにパンくずリストを表示させる設定ができました。

crumb ページ名 do
  link ビューに表示したい名前, URL
  parent 親ページ名
end

省略しましたがroutes.rbでroot 'tasks#index'としています。

viewファイルに記述

viewの表示させたい場所に記述していきます。
全てのページに表示させたい場合はapplication.html.erb

<!DOCTYPE html>
<html>
  <head>
    <title>GretelApp</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>
    <%= breadcrumbs separator: " &rsaquo; " %> <!--ここを追加 -->
    <%= yield %>
  </body>
</html>

とします。
ページの上部にある方が分かりやすいのでyieldの上に置いてます。
separator: " &rsaquo; "の部分は、親ページと子ページの間の文字を設定しています。
この場合、「>」になるんですが&rsaquo;となっているのは、「>」はHTMLタグと認識されたりする場合があるためこのように特殊文字になっています。
他にもオプションがあるのでgretelのgithubのOptionsの項を参考にしてください。

次に各ページのファイルにも記述していきます。
新規作成ページ(new_task_path)を

<h1>New Task</h1>

<% breadcrumb :new_task %> <!-- ここで呼び出し -->

<%= render 'form', task: @task %>

<%= link_to 'Back', tasks_path %>

このようにしました。
breadcrumb.rbで設定したページ名を指定することでパンくずリストを表示させます。
では確認してみましょう。
Image from Gyazo
うまく表示できました。

次は詳細ページに表示させます。
breadcrumb.rbに

crumb :task_show do |task|
  link "About #{task.title}", task_path(task)
  parent :root
end

詳細ページはIDが必要なのでブロック変数を使ってこのようにします。
viewには

<% breadcrumb :task_show, @task %>

このように記述します。

最後に編集ページです。

crumb :edit_task do |task|
  link 'Edit Task'
  parent :task_show, task
end

編集ページは一番下の階層なのでlinkにはURLは必要ありません。
parentにもtaskを渡しておきます。
viewの方も

<% breadcrumb :edit_task, @task %>

として確認してみましょう。
詳細ページ
Image from Gyazo
編集ページ
Image from Gyazo
このようにこのように階層を重ねることもできます。

最後に

無事実装できました。
関係ないですがパンくずって何のことだろうとずっと思ってたんですが由来が面白いですね。
なぜhanselではなくgretelになったのかも気になるところです。
では今回は以上です。ありがとうございました。

参考サイト

GitHub - kzkn/gretel: Flexible Ruby on Rails breadcrumbs plugin.
【Rails】 gretelを使ってパンくずリストを作成しよう | Pikawaka - ピカ1わかりやすいプログラミング用語サイト
Rails パンくずリスト gretel の使い方 - Qiita

slimを使ってみよう

RailsではデフォルトのテンプレートエンジンはERBですが、他にもslimhamlがあります。
今回はslimの記法について見て行きます。

テンプレートエンジンとは

HTML画面を直感的にわかりやすいテンプレート形式で記述することができます。

必要gemの導入

slimでhtmlを書くためのgemをインストールします。
・slim-rails
hoge.html.slimのようにビューファイルの拡張子にslimが使えるようになります。
・html2slim
既存のerbファイルをslim形式に変換してくれます。
Gemfileに

gem 'slim-rails'
gem 'html2slim'

として

bundle install

これでgemがインストールできました。
サーバーは再起動しておきます。

erbをslimに変換

変換するためにターミナルでコマンドを入力します。

bundle exec erb2slim app/views

で元のerbファイルを残したまま変換されたhoge.html.slimファイルが作成されます。
元のerbを削除するには

bundle exec erb2slim app/views -d

とします。

slimの特徴

slimの特徴として
・<>、閉じタグがいらない
・<%= %> は =
・<% %> は -
・class指定 は .
・id指定 は #
・テキストは | の後に
コメントアウトは /
が挙げられると思います。
scaffoldアプリを作ったのでどんな感じになっているか見てみましょう。

p#notice
  = notice
h1
  | Users
table
  thead
    tr
      th
        | Name
      th
        | Email
      th[colspan="3"]
  tbody
    - @users.each do |user|
      tr
        td
          = user.name
        td
          = user.email
        td
          = link_to 'Show', user
        td
          = link_to 'Edit', edit_user_path(user)
        td
          = link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' }
br
= link_to 'New User', new_user_path

Image from Gyazo

まだ慣れていないので変な感じはしますが、コード量が減ってすっきりしていますね。
同名のerbファイルが残っているとerbの方が反映されるのでslimに移行するときはerbファイルは削除する必要があります。

最後に

慣れが必要ですが、覚えてしまえばerbより書くのも読むのも早くなりそうですね。
ただ、あえてslimからerbに戻した、という記事も見つけたので現場によってはデフォルトのerbのまま、ということもありそうです。
では今回は以上です。ありがとうございました。

参考サイト

【Ruby on Rails】テンプレートエンジンの種類(ERB,Haml,Slim) | プログラミングマガジン
【爆速で習得】Railsでslimを使う方法から基本文法まで - Qiita
【Rails】 slimの書き方をマスターしよう! | Pikawaka - ピカ1わかりやすいプログラミング用語サイト

SystemSpecを書いてみよう

前回の続きで今回はSystemSpecを書いてみます。

SystemSpecとは

システムテストのことをRspecではシステムスペックと言います。
システムテストとは、実際に使用される状況と同じ設定でテストを行い、想定通りに動作するか検証することを言います。

テストの作成

spec/system/user_spec.rbを作成し、ここにユーザーに関するテストを書いていきます。

require 'rails_helper'

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

end

大きく分けて
・ページ遷移
・ユーザー新規作成
・ユーザー編集
・ユーザー削除
の4つに関するテストを作ります。
describeに対象、contextに条件、itに具体的なテスト内容を記述します。

require 'rails_helper'

RSpec.describe "Users", type: :system do
  describe 'ページ遷移確認' do
  end

  describe 'ユーザー新規作成' do
  end

  describe 'ユーザー編集' do
  end

  describe 'ユーザー削除' do
  end
end

まずは対象で分けてみました。
次はdescribeの中にcontextで条件を書いていきます。

require 'rails_helper'

RSpec.describe "Users", type: :system do
  describe 'ページ遷移確認' do
    context 'ユーザーの一覧ページに遷移' do

    end

    context 'ユーザーの新規作成ページに遷移' do

    end

    context 'ユーザーの編集ページに遷移' do

    end

    context 'ユーザーの詳細ページに遷移' do

    end
  end


  describe 'ユーザー新規作成' do
    context 'フォームの入力値が正常な場合' do

    end

    context '名前が未入力の場合' do

    end
  end

  describe 'ユーザー編集' do
    context 'フォームの入力値が正常な場合' do

    end

    context '名前が未入力の場合' do

    end
  end

  describe 'ユーザー削除' do

  end
end

こんな感じになりました。
テストは正常系異常系を作成します。
正常系は、全てのフォームが入力されている場合など、想定通りの操作をしたときの挙動を確認します。
異常系は、必須項目のフォームが空の場合など、想定外の操作をしたときの挙動を確認します。
なおユーザー削除は必ず成功する想定なので正常系のみです。

ページ遷移確認のテスト

RSpec.describe "Users", type: :system do
let(:user) { create(:user) }

  describe 'ページ遷移確認' do
    context 'ユーザーの一覧ページに遷移' do
      it 'ユーザーの一覧ページへのアクセスに成功する' do
        user_list = create_list(:user, 3)
        visit users_path
        expect(page).to have_content 'ユーザー一覧'
        expect(page).to have_content user_list[0].name
        expect(page).to have_content user_list[1].name
        expect(page).to have_content user_list[2].name
        expect(current_path).to eq users_path
      end
    end

    context 'ユーザーの新規作成ページに遷移' do
      it 'ユーザーの新規作成ページへのアクセスに成功する' do
        visit new_user_path
        expect(page).to have_content 'ユーザー新規作成'
        expect(current_path).to eq new_user_path
      end
    end

    context 'ユーザーの編集ページに遷移' do
      it 'ユーザーの編集ページへのアクセスに成功する' do
        visit edit_user_path(user)
        expect(page).to have_content 'ユーザー編集'
        expect(page).to have_field 'Name', with: user.name
        expect(page).to have_field 'Gender', with: user.gender
        expect(current_path).to eq edit_user_path(user)
      end
    end

    context 'ユーザーの詳細ページに遷移' do
      it 'ユーザーの詳細ページへのアクセスに成功する' do
        visit user_path(user)
        expect(page).to have_content 'ユーザー詳細'
        expect(page).to have_content user.name
        expect(page).to have_content user.gender
        expect(current_path).to eq user_path(user)
      end
    end
  end
end

例として2つ目のテストを挙げると
visit new_user_pathユーザー新規作成ページを訪れ
expect(page).to have_content 'ユーザー新規作成'そのページに「ユーザー新規作成」と表示されていて
expect(current_path).to eq new_user_path今いるpathがnew_user_path
ならテストが通ります。

let(:user) { create(:user) }は、itの中でその都度user = create(:user)でuserを作成するのではなく、予め定義だけしておいてuserで呼び出せるようにしています。

残りのテスト

全部説明すると長くなってしまうので残り一気にいっちゃいます。
最終的なコードがこちら。

require 'rails_helper'

RSpec.describe "Users", type: :system do
let(:user) { create(:user) }

  describe 'ページ遷移確認' do
    context 'ユーザーの一覧ページに遷移' do
      it 'ユーザーの一覧ページへのアクセスに成功する' do
        user_list = create_list(:user, 3)
        visit users_path
        expect(page).to have_content 'ユーザー一覧'
        expect(page).to have_content user_list[0].name
        expect(page).to have_content user_list[1].name
        expect(page).to have_content user_list[2].name
        expect(current_path).to eq users_path
      end
    end

    context 'ユーザーの新規作成ページに遷移' do
      it 'ユーザーの新規作成ページへのアクセスに成功する' do
        visit new_user_path
        expect(page).to have_content 'ユーザー新規作成'
        expect(current_path).to eq new_user_path
      end
    end

    context 'ユーザーの編集ページに遷移' do
      it 'ユーザーの編集ページへのアクセスに成功する' do
        visit edit_user_path(user)
        expect(page).to have_content 'ユーザー編集'
        expect(page).to have_field 'Name', with: user.name
        expect(page).to have_field 'Gender', with: user.gender
        expect(current_path).to eq edit_user_path(user)
      end
    end

    context 'ユーザーの詳細ページに遷移' do
      it 'ユーザーの詳細ページへのアクセスに成功する' do
        visit user_path(user)
        expect(page).to have_content 'ユーザー詳細'
        expect(page).to have_content user.name
        expect(page).to have_content user.gender
        expect(current_path).to eq user_path(user)
      end
    end
  end


  describe 'ユーザー新規作成' do
    before { visit new_user_path }

    context 'フォームの入力値が正常な場合' do
      it 'ユーザーの新規作成が成功する' do
        fill_in 'Name', with: 'test_name'
        select '男性', from: 'Gender'
        click_button '登録する'
        expect(page).to have_content 'User was successfully created.'
        expect(current_path).to eq '/users/1'
      end
    end

    context '名前が未入力の場合' do
      it 'ユーザーの新規作成が失敗する' do
        fill_in 'Name', with: nil
        select '男性', from: 'Gender'
        click_button '登録する'
        expect(page).to have_content '1 error prohibited this user from being saved:'
        expect(page).to have_content 'Nameを入力してください'
        expect(current_path).to eq users_path
      end
    end
  end

  describe 'ユーザー編集' do
    before { visit edit_user_path(user) }

    context 'フォームの入力値が正常な場合' do
      it 'ユーザーの編集が成功する' do
        fill_in 'Name', with: 'update_name'
        select '男性', from: 'Gender'
        click_button '更新する'
        expect(page).to have_content 'User was successfully updated.'
        expect(current_path).to eq '/users/1'
      end
    end

    context '名前が未入力の場合' do
      it 'ユーザーの編集が失敗する' do
        fill_in 'Name', with: nil
        select '男性', from: 'Gender'
        click_button '更新する'
        expect(page).to have_content '1 error prohibited this user from being saved:'
        expect(page).to have_content 'Nameを入力してください'
        expect(current_path).to eq user_path(user)
      end
    end
  end

  describe 'ユーザー削除' do
    let!(:user) { create(:user) }

    it 'ユーザーの削除が成功する' do
      visit users_path
      click_link 'Destroy'
      expect(page.accept_confirm).to eq 'Are you sure?'
      expect(page).to have_content 'User was successfully destroyed.'
      expect(current_path).to eq users_path
    end
  end
end

・ユーザー新規作成のbefore { visit new_user_path }
describe 'ユーザー新規作成'内の2つのcontextは両方ともまずnew_user_pathにアクセスするところから始まります。それぞれのテストにいちいち書くのはDRYに反しますね。なのでbeforeでまとめてしまいましょう。ユーザー編集のbeforeも同様です。
・ユーザー削除のlet!(:user) { create(:user) }
先ほどのletと違い、!がついています。
2つの違いはuserが作成されるタイミングです。

・letはuserが呼び出されたときに作成され、
・let!はテスト実行前に作成されます。

はい、よくわかりません。
具体的に見てみます。ユーザー編集の正常系のテストはこのようになっています。

RSpec.describe "Users", type: :system do
let(:user) { create(:user) }

略

  describe 'ユーザー編集' do
    before { visit edit_user_path(user) }

    context 'フォームの入力値が正常な場合' do
      it 'ユーザーの編集が成功する' do
        fill_in 'Name', with: 'update_name'
        select '男性', from: 'Gender'
        click_button '更新する'
        expect(page).to have_content 'User was successfully updated.'
        expect(current_path).to eq '/users/1'
      end
    end

該当のユーザーの編集ページを訪れ
Nameに「update_name」と入力し
Genderは「男性」を選択
「更新する」ボタンをクリックすると
ページに「User was successfully updated.」と表示されていて
そのページのpathは「/users/1」になっている
とテストが成功する。

という内容です。
この最初のvisit edit_user_path(user)、ここでuserが出てきてます。このタイミングで実際にcreate(:user)されています。

次にユーザー削除のテストを見てみましょう。

  describe 'ユーザー削除' do
    let!(:user) { create(:user) }

    it 'ユーザーの削除が成功する' do
      visit users_path
      click_link 'Destroy'
      expect(page.accept_confirm).to eq 'Are you sure?'
      expect(page).to have_content 'User was successfully destroyed.'
      expect(current_path).to eq users_path
    end
  end

こちらはlet!(:user) { create(:user) }としてあります。
このテストは

users_path(ユーザー一覧ページ)を訪れ
「Destroy」というリンクをクリック
アラートで「Are you sure?」と表示され
OKすると「User was successfully destroyed.」と表示され
users_pathに遷移される

とテスト成功です。
が、itの中を見てみるとuserはどこにも呼び出されていません。
しかしそもそもユーザーが1件もないとDestroyというリンクは表示されないような実装にしているので、itの中でuserを作成する必要があります。
let(:user) { createa(:user) }と定義しているだけではuserは作成されないので、let!(:user) { create(:user) }でテスト実行前に予め作成しておかないといけません。

ユーザー削除のテストの結果をletとlet!で見比べてみます。
letの場合

Image from Gyazo

let!だと想定通り、テストが通りますが、
letの場合

Image from Gyazo

「Destroy」なんて見つからない、と言われてしまいます。
前述の通り、ユーザーが0件だと「Destroy」が表示されない、つまりユーザーが作成されていない。
削除のテストを実行するためにはやはりletではなくlet!でユーザーの作成が必要だとわかります。

最後に

簡単なCRUDアプリなのでシンプルなテストになりました。もっと多機能なアプリだとテストも複雑になりそうです。あとテストではハードコーディングしたほうがいいのかなー、と思ったんですがどうなんでしょう。テスト件数が増えるとメンテナンスが大変になりそうですがそっちの方が堅牢なテストになりそうな気もします。
今回も自分にとってしっくり来る表現をしたので厳密には間違ってたりするかもしれません。ご指摘あればコメントいただけると嬉しいです。
長くなりましたが、読んでいただきありがとうございました。

参考サイト

使えるRSpec入門・その4「どんなブラウザ操作も自由自在!逆引きCapybara大辞典」 - Qiita
Module: Capybara::Node::Actions — Documentation for jnicklas/capybara (master)
システムテストとは?開発段階のテストの流れと主な種類|発注成功のための知識が身に付く【発注ラウンジ】<
RSpecの(describe/context/example/it)の使い分け - Qiita
【直感的に書ける!】RspecでRubyのテストを覚えよう! | プロぽこ
RSpec の letとlet!とbeforeの挙動と実行される順番 - Qiita

Rspecを書いてみよう

初めてテストコードを書いたのでおさらいのためにまとめてみます。
RailsはMInitestというテスティングフレームワークを備えていますが今回は
以前作った簡単なCRUDアプリ
を元にRspecを導入してテストを書いてみました。
テストが必要なほどの機能はないのですが練習のため。
2つの違いはよくわかってませんがRspecの方が利用率は高いそうです。
ではまずは導入からやっていきます。

必要gemのインストール

必要なgemを4つインストールします。

group :development, :test do
  gem 'rspec-rails'
  gem 'factory_bot_rails'
end

group :test do
  gem 'capybara'
  gem 'webdrivers'
end

rspec-rails

テスティングフレームワーク
これがないと始まらない。
GitHub - rspec/rspec-rails: RSpec for Rails 5+

factory_bot_rails

テスト用のデータを簡単に作成できるgem。
GitHub - thoughtbot/factory_bot_rails: Factory Bot ♥ Rails

capybara

E2Eテスティングフレームワーク
ブラウザでテストしてくれる。
GitHub - teamcapybara/capybara: Acceptance test framework for web applications

webdrivers

調べたんですがあんまりわかってません。
capybaraとセットで使われるっぽい。
selenium-webdriverchromedriver-helperの代わりにこのwebdrivers1つでいいみたいです。
GitHub - titusfortner/webdrivers: Keep your Selenium WebDrivers updated automatically

bundle install

でインストールします。

諸々の設定

rails g rspec:install

とすると

Running via Spring preloader in process 63316
      create  .rspec
      create  spec
      create  spec/spec_helper.rb
      create  spec/rails_helper.rb

これらが作成されます。
.rspec--format documentationを追加するとテスト結果の表示が変わります。
お好みで設定しましょう。

rails_helper.rbの

Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f }

これのコメントアウトを外すとspec/support以下のファイルを読み込みます。
モジュールなどを作成する場合はsupportディレクトリを作成し、その中に置きます。
このアプリにログイン機能はまだないので以下は例です。

# support/system_helper.rb

module LoginModule
  def login(user)
    visit '/login'
    fill_in 'Name', with: user.name
    fill_in 'Email', with: user.email
    fill_in 'Password', with: '00000000'
    click_button 'Login'
  end
end

support/capybara.rbを作成して

RSpec.configure do |config|
  config.before(:each, type: :system) do
    driven_by :selenium_chrome_headless
  end
end

ドライバーの設定もしておきます。
selenium_chrome_headlessを指定しておくとテスト実行の度にブラウザを立ち上げずにテストしてくれます。

テスト用データの作成

rails g rspec:model user

このコマンドでmodels/user_spec.rbとfactories/user.rbが作成されます。

FactoryBot.define do
  factory :user do
    
  end
end

こちらがfactories/user.rbの中身です。
ここでテスト用のデータを作成します。 userモデルとスキーマのusersテーブルは

class User < ApplicationRecord
  validates :name, presence: true
  enum gender: { male: 0, female: 1, secret: 2 }
end
  create_table "users", force: :cascade do |t|
    t.string "name", null: false
    t.integer "gender", default: 0, null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

こんな感じです。

FactoryBot.define do
  factory :user do
    name { 'test_name' }
    gender { 'male' }
  end
end

名前と性別を設定しておきました。
テストで使うデータの初期値を設定しておく、というイメージです。
また、rails_helper.rbに

RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods
end

これを追加しておくと、テストでデータを作るときに

# 設定なし
user = FactoryBot.build(:user)
# 設定あり
user = build(:user)

このように省略できるので設定しておきます。

テストの作成

ようやくテストの作成です。
今回はバリデーションに関するテストを作ってみます。
先ほど作成したmodels/user_spec.rbに

require 'rails_helper'

RSpec.describe User, type: :model do
  describe 'バリデーション' do
    it '全てのフォームが正常な場合に成功する' do
      user = build(:user)
      expect(user).to be_valid
      expect(user.errors).to be_empty
    end

    it 'Nameが空の場合に失敗する' do
      user = build(:user, name: nil)
      expect(user).to be_invalid
      expect(user.errors[:name]).to eq ['を入力してください']
    end
  end
end

2つのテストを書いてみました。

1つ目のテストを説明すると、
・user = build(:user)
factories/user.rbの情報を元にユーザーを作成しuserに代入
・expect(user).to be_valid
userがバリデーションを通ることを期待
・expect(user.errors).to be_empty
user.errorsが空であることを期待
となります。

2つ目だと
・user = build(:user, name: nil)
初期値を元にするがnameは空にする
・expect(user).to be_invalid
userがバリデーションに引っかかることを期待
・expect(user.errors[:name]).to eq ['を入力してください']
エラー文、「を入力してください」と表示されることを期待
となります。

それではテストを実行してみましょう。

bundle exec rspec

とすると

Image from Gyazo

通りました。

最後に

ほんとに簡単なところだけですがテストを作って実行するところまでできました。実装のコードは間違っていたらエラーが出ますが、テストはそもそもテストコードをちゃんと書かないと意味を成さない気がするので気を付けたいです。何か間違いなどありましたらコメントいただけると嬉しいです。
では今回もありがとうございました。

参考サイト

E2Eテスト
RSpecとminitestのおおまかな違い - Qiita
FactoryBotでテストデータ作成する方法 - Qiita
【Rails】『RSpec + FactoryBot + Capybara + Webdrivers』の導入&初期設定からテストの書き方まで | vdeep