バッチ処理の実装
今回は記事投稿アプリにバッチ処理の実装をしてみます。
最終的にはこんなことをやりたいです。
やりたいこと
記事の状態が「下書き」「公開」「公開待ち」と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_wait
はenumの定義により使えるようになったもので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
library rake (Ruby 3.0.0 リファレンスマニュアル)
assign_attributesメソッド
記事投稿アプリで公開日が過去の日付なら「公開」、未来の日付なら「公開待ち」と状態を変えるのにつまづいたので忘備録として残しておきます。
前提として記事の状態state
はenumで定義されています。
# 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アクションでは公開日がまだ来ていないにも関わらず、公開されてしまいます。
これは@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
ではこれで再度確認してみます。
未来の日付で状態を公開にし
更新すると
無事、状態が公開待ちに変わりました。
最後に
updateメソッドとはassign_attributes + saveだったんですね。
間に何か処理を挟みたいときにassign_attributesを使うと良さそうです。
updateもupdate_attributesのalias(別名)なので
・update_attributes(DB更新あり)
・assign_attributes(DB更新なし)
のように並べてみると分かりやすいですね。
では今回は以上です。ありがとうございました。
参考サイト
まとめてオブジェクトの属性を変更したい時に便利!assign_attributesメソッド - その辺にいるWebエンジニアの備忘録
ActiveModel::AttributeAssignment
エラーメモ【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型だから受け取れない、とのことなのでまずは実際に何型のオブジェクトか見てみます。
sentence.body.class
でsentence.body
がnil型だと分かりました。
これをstring型にできれば解決するはずです。
どうやって型を変えるか
初めに思いついたのはto_s
メソッドで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
このようにしてエラー解決できました。
||= は何をしているか
||=
は自己代入やnilガード
と呼ばれるそうです。
左辺がnilまたはfalseの場合は右辺が代入され、それ以外の場合は代入はされません。
sentence.bodyに「テスト」と入力して確認してみます。
入力されているのでnilにはならないはずです。
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?) | 侍エンジニアブログ
パンくずリストを作ろう
今回はパンくずリスト
を作ってみたいと思います。
パンくずリストとは
自分が今サイトのどこにいるかわかりやすく表示したものです。
こんなやつです。
アプリ立ち上げ
アプリの新規作成からやっていきます。
rails new gretel_app
名前はgretel_appとしておきます。
ターミナルでgretel_appに移動したら
rails g scaffold task title:string body:text
こんな感じでscaffoldして
rails db:migrate
マイグレートして
rails s
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: " › " %> <!--ここを追加 --> <%= yield %> </body> </html>
とします。
ページの上部にある方が分かりやすいのでyieldの上に置いてます。
separator: " › "
の部分は、親ページと子ページの間の文字を設定しています。
この場合、「>」になるんですが›
となっているのは、「>」は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で設定したページ名を指定することでパンくずリストを表示させます。
では確認してみましょう。
うまく表示できました。
次は詳細ページに表示させます。
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 %>
として確認してみましょう。
詳細ページ
編集ページ
このようにこのように階層を重ねることもできます。
最後に
無事実装できました。
関係ないですがパンくずって何のことだろうとずっと思ってたんですが由来が面白いですね。
なぜhanselではなくgretelになったのかも気になるところです。
では今回は以上です。ありがとうございました。
参考サイト
GitHub - kzkn/gretel: Flexible Ruby on Rails breadcrumbs plugin.
【Rails】 gretelを使ってパンくずリストを作成しよう | Pikawaka - ピカ1わかりやすいプログラミング用語サイト
Rails パンくずリスト gretel の使い方 - Qiita
slimを使ってみよう
RailsではデフォルトのテンプレートエンジンはERBですが、他にもslim
やhaml
があります。
今回は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
まだ慣れていないので変な感じはしますが、コード量が減ってすっきりしていますね。
同名の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の場合
let!だと想定通り、テストが通りますが、
letの場合
「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-webdriver
、chromedriver-helper
の代わりにこのwebdrivers
1つでいいみたいです。
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
とすると
通りました。
最後に
ほんとに簡単なところだけですがテストを作って実行するところまでできました。実装のコードは間違っていたらエラーが出ますが、テストはそもそもテストコードをちゃんと書かないと意味を成さない気がするので気を付けたいです。何か間違いなどありましたらコメントいただけると嬉しいです。
では今回もありがとうございました。
参考サイト
E2Eテスト
RSpecとminitestのおおまかな違い - Qiita
FactoryBotでテストデータ作成する方法 - Qiita
【Rails】『RSpec + FactoryBot + Capybara + Webdrivers』の導入&初期設定からテストの書き方まで | vdeep