12/17 今日学んだこと(RSpec復習)
本日からは既存のアプリのエラーをRSpecを書きながら修正していきますので、学んだことを記録していこうと思います。
RSpec
権限の扱い
管理者権限を持つユーザーをFactoryBotで作るときは、無闇にadmin属性を持つユーザーが作られないようにtraitを使うと良い。
デフォルトで作られるユーザーは、一般属性にするべし。
FactoryBot.define do factory :user do sequence(:name) { |n| "user_#{n}" } password { "password" } password_confirmation { "password" } role { :writer } trait :admin do sequence(:name) { |n| "admin-#{n}" } role { :admin } end trait :editor do sequence(:name) { |n| "editor_#{n}" } role { :editor } end trait :writer do sequence(:name) { |n| "writer_#{n}" } role { :writer } end end end
わかりやすく書く
システムスペックファイルの名前は、何のテストか一目でわかるものにする。また、テストコードのdescribeやcontextも具体的に記載する。 また、テストするユーザーの権限に合わせて、letで定義するオブジェクトの名前も変えると良い。
最初のコード
#articles_spec.rb RSpec.describe "Articles", type: :system do describe 'ログイン後' do describe '管理者でログイン' do let(:user){ FactoryBot.create(:user) } before { login_as(user) } context '画像が未選択' do fit 'プレビューが正しく表示される' do visit new_admin_article_path fill_in 'タイトル', with: 'title' fill_in 'スラッグ', with: 'slag' fill_in '概要', with: 'body' click_on '登録する' click_on 'ブロックを追加する' click_on '画像' click_on 'プレビュー' expect(page).to have_content 'Blog' expect(page).to have_content 'title' end end end end end
ファイル名も曖昧だしコードもごちゃごちゃで何をテストしたいかわかりづらい。
理想コード
#admin_articles_previews_spec/rb RSpec.describe "AdminArticlesPreviews", type: :system do let(:admin){ create :user, :admin } describe '記事作成画面で画像ブロックを追加' do context '画像を選択せずにプレビューを閲覧' do fit 'プレビューが正常に表示される' do login_as(admin) visit new_admin_article_path fill_in 'タイトル', with: 'title' fill_in 'スラッグ', with: 'slag' fill_in '概要', with: 'body' click_on '登録する' click_on 'ブロックを追加する' click_on '画像' click_on 'プレビュー' expect(page).to have_content 'Blog' expect(page).to have_content 'title' end end end end
エラー集
LoginMacrosが動かない。うまくfill_inできない。
初期データが投入されていないのでそもそもページができていない。 テスト環境にシードデータを入れることで解決
config.before :suite do SeedFu.seed end
こうすることでspecを走らせるたびにシードデータが投入されるようになる。
【RSpec】システムスペックを実装してみた②
今回もRSpecのシステムスペックについてです。前回は新規でコードを実装しましたが、今回は既存のコードの修正を行いました。 その過程で学んだことについて記します。
FactoryBotのtraitについて
FactoryBotの以下のコードを使って、taskの編集に関するテストで使用するtaskオブジェクトを定義する場合
FactoryBot.define do factory :task do association :project title { 'Task' } status { rand(2) } from = Date.parse("2019/08/01") to = Date.parse("2019/12/31") deadline { Random.rand(from..to) }
task = FactoryBot.create(:task, project_id: project.id, status: :done, completion_date: Time.current.yesterday)
このようになりだいぶ冗長になってしまう。 そのため、FactoryBotのtrait機能を使ってDRYにする。
FactoryBot.define do factory :task do association :project title { 'Task' } status { rand(2) } from = Date.parse("2019/08/01") to = Date.parse("2019/12/31") deadline { Random.rand(from..to) } trait :done do status { :done } completion_date { Time.current.yesterday } end end end
task = FactoryBot.create(:task, :done, project_id: project.id)
このように、引数に:done
と渡してやるだけで実装できる。
別タブに移動した後の動作をテストする場合
windows = page.driver.browser.window_handles page.driver.browser.switch_to.window(windows.last)
このように書くことで、別タブをスコープにテストを実行できる。
なお、この場合カレントタブは変わらずそのままである。
カレントタブも移動する場合は
switch_to_window(windows.last)
で移動できる。
テストの中でアラートメッセージを押す
削除機能テストなどで、confirmウィンドウを操作したいときは
#承認するとき page.driver.browser.switch_to.alert.accept #拒否するとき page.driver.browser.switch_to.alert.dismiss
で実装できる。
【RSpec】システムスペックを実装してみた
今回はRSpecのシステムスペックを実装しました。
学んだことを簡単に箇条書き。
学んだ事
beforeについて
before do visit edit_task_path(task) end
は、
before { visit edit_task_path(task) }
と1行で書く事ができる。
select_fieldのテスト
select ‘doing’, from: ‘Status’
Status
のラベルをもつselect_fieldから、doing
を選んでくれる。
confirmダイアログのテスト
expect(page.accept_confirm).to eq “Are you sure?”
このように書く事で、自動的にOKも押してくれる。
letとlet!の違い
簡単にいうと、let
は定義された定数が呼び出されない限り発動しない。
それに対して、let!
は常にその定数が発動している状態になる。
そのため・・・
describe 'タスクの削除', focus: true do let!(:task) { create(:task, user: user) } it 'タスクの削除が成功する' do visit tasks_path click_link 'Destroy' expect(page.accept_confirm).to eq "Are you sure?" expect(page).to have_content "Task was successfully destroyed." expect(current_path).to eq tasks_path expect(page).not_to have_content task.title end end
上のタスク削除のテストは、let
だと動作しない!!
(letだと、そもそもtask自体が作られずDestroyへのlinkも作成されず、Unable to find link "Destroy"
のエラーが表示される。)
【RSpec】バリデーションテストを実装してみた
掲示板アプリで一通りのことを学んだので、12月からRSpecを学び始めました。
RSpec用のsample_appに、これから色々なテストを実装していきます。
掲示板アプリを作成したときと同様、学んだことやエラーの解決方法を順番に備忘録として残していきます。
バリデーションテストで学んだこと
・rails_helperに設定を追加すればFactory_Botの記述を消す事が可能
#spec/rails_hepler内 config.include FactoryBot::Syntax::Methods
・unique属性を持ってるものはsequenceで記述する
#spec/factories/tasks.rb内 FactoryBot.define do factory :task do association :user # associationの記述だけでuserとの紐付けが完了する sequence(:title, "title_1") #unique属性を持ってるものはsequenceで記述する #このようにブロックではない第二引数を渡した場合でも、末尾に.nextが呼ばれて末尾の数字が自動的に増えていく。 content { "count drinks" } status { :todo } # statusなどのenumで別の属性を付与しているものは、文字列ではなくシンボルで記述するとよい。 deadline { 1.week.from_now } # deadlineなどは、時間を決め打ちするのではなく相対表記にする。 # 決め打ちしていると、将来的に過去の時間になってしまう。 end end
・sequence(:title, “title_1”)
といった感じでブロックを渡さずに第二引数を渡すと、 .next が呼ばれる。つまり末尾の数字が増えていく。
・statusなどのenumで別の属性を付与しているものは、文字列ではなくシンボルで記述するとよい。
・deadlineは相対表現にする。時間を決め打ちしてしまうと時間が経った後にテスト結果が変わってしまう。(将来的に過去の日付になってしまう)
・バリデーションテスト時の変数は、一目で何のテスト用かわかるようにする bad: task good: task_without_title
・バリデーションのテストコードは、基本buildでかく。createだとデータベースに保存を試みてしまうので、テスト用の欠陥データがバリデーションに引っかかってテストすらできない。
#spec/models/task_spec.rb内 it 'is invalid with a duplicate title' do task = create(:task) # まずはcreateで最初のデータをデータベースに保存している task_with_duplicate_title = build(:task, title: task.title) expect(task_with_duplicate_title).to be_invalid # be_invalidのテストコードを書かないとエラーが起こる expect(task_with_duplicate_title.errors[:title]).to eq ["has already been taken"] end
例外処理とSlack通知の実装
掲示板アプリ制作の締め括りに、例外処理及び500エラーが出た際にSlackに通知が飛ぶようにしました。
実装を通して学んだことを記します。
参考にさせていただいた記事
slack-notifierでrailsからSlackへ簡単にメッセージを送る - Qiita
【Rails】500番エラーをslack通知する - Qiita
Railsアプリの例外ハンドリングとエラーページの表示についてまとめてみた - Qiita
学んだ事
Credentialsについて
Rails5.2から追加された機密情報を保持する仕組み。 Credential.yml.encという秘匿情報を記録するファイルと、 Master.keyという秘匿情報を閲覧したり、編集したりするための鍵となるファイルが要
$ EDITOR='vi' bundle exec rails credentials:edit
コマンドでmaster.keyを使ってvi
でcredential.yml.encを開く事ができる。
参考にさせていただいた記事
credentials.ymlの書き方 - annzuwatanuki’s diary
Railscredentials.yml.encについてまとめる - Qiita
開発環境でエラーを表示させる
開発環境ではデフォルトではそのままのエラー文が表示されている
config/environments/development.rb
内のconfig.consider_all_requests_local: true
をfalse
に変える事で本番環境用のエラーviewを表示できるようになる。
エラーの継承関係
エラーにも継承関係が存在し、exceptionが全ての親クラスである。
アプリケーション全体にエラーを適用したいときは、rescue_fromメソッドを使う。
# application_controller内 unless Rails.env.development? rescue_from Exception, with: :error_500 rescue_from ActiveRecord::RecordNotFound, with: :error_404 end def error_404 render file: Rails.root.join('public', '404.html'), layout: false, status: :not_found end def error_500(error) logger.error error logger.error error.backtrace.join("\n\n") render file: Rails.root.join('public', '500.html'), layout: false, status: :internal_server_error end
このとき、rescue_from
は必ず親クラスから記載する。これは、例外処理の際に下のコードから順に読み込むためであり、親クラスの例外処理であるrescue_from Exception, with: :error_500
を下に書いてしまうと全てerror_500として処理されてしまうため。
#この書き方でも同じように実装できる def error_404 render file: Rails.root.join('public', '404.html'), layout: false, status: :not_found end def error_500(error) logger.error error logger.error error.backtrace.join("\n\n") render file: Rails.root.join('public', '500.html'), layout: false, status: :internal_server_error end
ルーティングの記載
404_errorと500_errorのviewを表示させるためにルーティングを書く必要がある。このとき、必ずroutesの一番下に書く事!!
上の方に書いてしまうとほとんど全てのページで404エラーや500エラーが表示されてしまう。
#routes.rb内 . . . get '*path', to: 'application#error_404' get '*path', to: 'application#error_500'
エラー解決
存在しないページのURLに飛んだ際に、本来404_errorにならないといけないのに500_errorが表示されてしまうエラーが発生
アクセスしたページは、localhost:3000/boards/1000
config.consider_all_requests_local: true
に戻してエラー内容を見てみるとNo_method_errorになっている。
NoMethodError in BoardsController#show undefined method `comments' for nil:NilClass
404エラーにするためには、ActiveRecord::RecordNotFound
にしないといけない。
これはどうやら、
@board = Board.find_by(params[:id])
でfind_byメソッドを使っていた事が原因らしい。
find_byはidが存在しない場合はnilを返すため、@boardがnilになってしまっていたらしい。
@board = Board.find(params[:id])
に変更する事で無事ActiveRecord::RecordNotFound
を表示させる事ができた。
Railsチュートリアルの時からnilの概念と重要性について聞いてはいたが、結局はうまく動くか動かないかの2つだと考えていたのであまり気にした事がなかった。
今回の例外処理の実装を通してようやく点と点が繋がった。
Admin-LTE3を使用した管理画面の実装③(掲示板/ユーザーのCRUD)
前回に続いて、Admin-LTE3を使った管理画面の実装を進めていきました。 その中で学んだ事をまとめました。
enum_helpを使用したプルダウンのセレクトボックスの実装
セレクトボックスの実装で少し詰まったので、色々な記事を参考にして自分なりに解釈し、わかりやすくまとめてみた。
#権限選択画面のコード <%= f.select :role_eq, User.roles_i18n.invert.map{|key, value| [key, User.roles[value]]}, { include_blank: t(‘defaults.unspecified’) }, { class: ‘form-control mr-1’ } %>
User.roles_i18n.invert.map{|key, value| [key, User.roles[value]]}
の部分が非常にややこしいので順に読み解いていく。
まずはenum_helpを使って日本語化する前のコード
<%= f.select :role_eq, User.roles.values %>
#rails console内 User.roles => {"general"=>0, "admin"=>1} User.roles.values => [0, 1]
User.roles.values
により、roleカラムの値を配列として取得できる。
これを、enum_helpを使用しi18nで日本語化する。
#rails.console内 User.roles_i18n => {"general"=>"一般", "admin"=>"管理者"} User.roles_i18n.values => ["一般", "管理者"]
# admin/users/index.html.erb内 <%= search_form_for(@q, url: admin_users_path, method: :get) do |f| %> <div class="input-group mb-3"> <%= f.label :role_eq, '権限' %> <%= f.select :role_eq, User.roles_i18n.values, include_blank: '指定なし' %> </div> <% end %>
しかし、このコードだと発行されるSQLが常に”role” = 0
となってしまい正常に動作しない。
Processing by Admin::UsersController#index as HTML Parameters: {"utf8"=>"✓", "q"=>{"first_name_or_last_name_cont"=>"","role_eq"=>"管理者"}, "commit"=>"検索"} User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 8], ["LIMIT", 1]] ↳ vendor/bundle/ruby/2.6.0/gems/activerecord-5.2.3/lib/active_record/log_subscriber.rb:98 Rendering admin/users/index.html.erb within layouts/admin_application User Load (0.3ms) SELECT DISTINCT "users".* FROM "users" WHERE "users"."role" = 0 ORDER BY "users"."created_at" DESC ↳ app/views/admin/users/index.html.erb:23
どうやらこれは、ransackがそもそもenumに対応していない事が原因で、status_idが常に0になってしまうというバグらしい。
参考記事
RansackはRailsのenumに対応していないっぽい | 地方でリモートワーク
そこで、invertメソッドとmapメソッドを組み合わせてstatus_idを正しく取得できるようにする。
Invertメソッドとmapメソッドの挙動について
#rails.console内 User.roles_i18n => {"general"=>"一般", "admin"=>"管理者"} User.roles_i18n.invert => {"一般"=>"general", "管理者"=>"admin"} User.roles_i18n.invert.map{|key,value| [key, User.roles[value]]} => [["一般", 0], ["管理者", 1]]
3つ目の挙動が少し難しいかもしれないが、これは、
“一般” “管理者”というキーが key に、”general” “admin”という値が valueにそれぞれ代入され、mapメソッドによってそれぞれに対して処理が行われている。
参考記事
ハッシュのキー、値の配列を取得する (keys, values) | まくまくRubyノート
#rails.console内 User.roles["general"] => 0 User.roles["admin"] => 1
この結果、一般タブが選択されるとvalue=“0”
が、管理者タブが選択されるとvalue="1"
が選択されるようになり、正常に動作するようになった。
#発行されているhtml <select name="q[role_eq]" id="q_role_eq"><option value="">指定なし</option> <option value="0">一般</option> <option value="1">管理者</option></select>
ransackのカスタムpredicateについて
Ransackでは様々な検索機能がある。また、検索に使うpredicate(デフォルトで用意されている検索用のメソッドのようなもの)は、カスタムすることも可能である。
今回の日付範囲検索は、デフォルトpredicateのcreated_at_lteq
だと、0:00までとなってしまい、正しく検索する事ができないので、created_at_lteq_end_of_day
というカスタムpredicateを作る必要がある。
Config/initializerに、ransack.rbというファイルを作って以下のコードを記述する
# config/initializers/ransack.rb Ransack.configure do |config| config.add_predicate ‘lteq_end_of_day’, arel_predicate: ‘lteq’, formatter: proc { |v| v.end_of_day } end
arel_predicate: ‘lteq’
でカスタマイズしたいpredicateを指定し、
formatter: proc { |v| v.end_of_day }
の箇所で、23:59:59までを指定している。
class=“active”を動的にして、サイドバーのアクティブ機能を実装する
Admin/boardsコントローラーのアクションが実行されているときは、「掲示板一覧」が、Admin/usersコントローラーのアクションが実行されているときは「ユーザー一覧」がアクティブになるようにする。
Application.helerにこのように記述し、
def active?(controller_name) return 'active' if controller_name == params[:controller] end
これはあまり綺麗ではない。三項演算子を使って、else時の返り値も明示してあげた方が良い。
修正後のコード
def active_if(path) path == controller_path ? 'active' : '' end
# _sidebar.html.erb内 <%= link_to admin_boards_path, class: "nav-link #{active_if('admin/boards')}" do %>
Admin-LTE3を使用した管理画面の実装②(新しい知識と学んだ事)
今回の実装を通して学んだことをまとめていきます
◆Userに権限を付与する
一般ユーザーと管理者ユーザーを分けるために、Usersテーブルに、roleという名前のカラムを作成する。(名前はわかればなんでもおk)
データ型はintegerにして、enumオプションを使用し「0: 一般、1: 管理者」という風に別名をつける。
#migrationファイル内 class AddRoleToUsers < ActiveRecord::Migration[5.2] def change add_column :users, :role, :integer, default: 0, null: false, limit: 1 #defaultを一般ユーザーの0に設定し、limitを1にすることで、ユーザーは0 or 1の2種類に振り分けられる。 end end
#User.rb内 enum role: { general: 0, admin: 1 } #enumオプションを使うことで、0,1という数字の代わりにgeneralとadminという属性をUserに持たせる事ができる。 end
◆管理画面用のコントローラーを作成する
Admin関連のコントローラーは、admin/というディレクトリで一括管理した方が保守性が高いため、adminというnamespaceで区切ってコントローラーを作成するとよい。
rails g controller admin/base
rails g controller admin/user_sessions
rails g controller admin/dashboards
また、admin/user_sessionsコントローラーと、admin/dashboardsコントローラーはadmin/baseコントローラーを継承するように設定する。
こうすることで、admin関連のページ全体で使用したいメソッドやbefore_actionなどをbaseコントローラーに定義することで全てのコントローラーに適用されるようになる。
class Admin::UserSessionsController < Admin::BaseController
class Admin::DashboardsController < Admin::BaseController
◆namespaceを使ったURL設定
URLもコントローラー同様、adminというネームスペースを使用して一般画面とは切り離すと良い。
ネームスペースを使用することで普通にルーティングしたときと比べてどのような違いがあるのかについては、こちらのQiita記事を参考にさせていただきました。
Railsのroutingにおけるscope / namespace / module の違い - Qiita
namespace :admin do get '/login', to: 'user_sessions#new' post '/login', to: 'user_sessions#create' delete '/logout', to: 'user_sessions#destroy' root 'dashboards#index' end
こうすることで、ルーティングが以下のようになり、path,URL,コントローラー全てをadminというネームスペースで切り分ける事ができる。
admin_login GET /admin/login(.:format) admin/user_sessions#new POST /admin/login(.:format) admin/user_sessions#create admin_logout DELETE /admin/logout(.:format) admin/user_sessions#destroy admin_root GET /admin(.:format) admin/dashboards#index
URLもコントローラー同様、adminというネームスペースを使用して一般画面とは切り離すと良い。
ネームスペースについてはこちらのQiita記事を参考にさせていただきました。 Railsのroutingにおけるscope / namespace / module の違い - Qiita
namespace :admin do get '/login', to: 'user_sessions#new' post '/login', to: 'user_sessions#create' delete '/logout', to: 'user_sessions#destroy' root 'dashboards#index' end
こうすることで、ルーティングが以下のようになり、path,URL,コントローラー全てをadminというネームスペースで切り分ける事ができる。
admin_login GET /admin/login(.:format) admin/user_sessions#new POST /admin/login(.:format) admin/user_sessions#create admin_logout DELETE /admin/logout(.:format) admin/user_sessions#destroy admin_root GET /admin(.:format) admin/dashboards#index
URLもコントローラー同様、adminというネームスペースを使用して一般画面とは切り離すと良い。
ネームスペースについてはこちらのQiita記事を参考にさせていただきました。 Railsのroutingにおけるscope / namespace / module の違い - Qiita
namespace :admin do get '/login', to: 'user_sessions#new' post '/login', to: 'user_sessions#create' delete '/logout', to: 'user_sessions#destroy' root 'dashboards#index' end
こうすることで、ルーティングが以下のようになり、path,URL,コントローラー全てをadminというネームスペースで切り分ける事ができる。
admin_login GET /admin/login(.:format) admin/user_sessions#new POST /admin/login(.:format) admin/user_sessions#create admin_logout DELETE /admin/logout(.:format) admin/user_sessions#destroy admin_root GET /admin(.:format) admin/dashboards#index
URLもコントローラー同様、adminというネームスペースを使用して一般画面とは切り離すと良い。
ネームスペースについてはこちらのQiita記事を参考にさせていただきました。 Railsのroutingにおけるscope / namespace / module の違い - Qiita
namespace :admin do get '/login', to: 'user_sessions#new' post '/login', to: 'user_sessions#create' delete '/logout', to: 'user_sessions#destroy' root 'dashboards#index' end
こうすることで、ルーティングが以下のようになり、path,URL,コントローラー全てをadminというネームスペースで切り分ける事ができる。
admin_login GET /admin/login(.:format) admin/user_sessions#new POST /admin/login(.:format) admin/user_sessions#create admin_logout DELETE /admin/logout(.:format) admin/user_sessions#destroy admin_root GET /admin(.:format) admin/dashboards#index
◆User_sessionsコントローラーの書き方
最初はcreateアクションをこのようにコーディングしていた。
def create @user = login(params[:email], params[:password]) if @user.present? && @user.admin? flash[:success] = 'ログインしました' redirect_to admin_root_path elsif @user.present? && @user.general? flash[:danger] = '権限がありません' redirect_to root_path else flash[:danger] = 'ログインに失敗しました' redirect_to admin_login_path end end
elsifを使っているので条件分岐が3つになり、コードが少々冗長になっている。
しかし、実際には管理画面全体をAdminオンリーで制限をかけるので、AdminかGeneralかの判定はBase_controllerのbefore_actionで定義した方が良い。
修正後のコードがこちら
class Admin::BaseController < ApplicationController before_action :check_admin layout 'layouts/admin_application' # layoutで、admin_applicationを明示的に指定する事で、管理画面全体にadmin_application.html.erbがデフォルトで適用される。 private def not_authenticated flash[:warning] = t('defaults.message.require_login') redirect_to admin_login_path end def check_admin redirect_to root_path, warning: t('defaults.message.not_authorized') unless current_user.admin? end end
class Admin::UserSessionsController < Admin::BaseController skip_before_action :check_admin, only: %I[new create] skip_before_action :require_login, only: %I[new create] layout 'layouts/admin_login', layout: false # ログインページのみ、ヘッダーやフッター、サイドバーのないシンプルなフォームにするためlayout: falseでadmin_application.html.erbが適用されないように設定している。 def new; end def create @user = login(params[:email], params[:password]) if @user redirect_to admin_root_path, success: t(‘.success’) else flash.now[:danger] = t(‘.fail’) render :new end end def destroy logout redirect_to admin_login_path, success: t(‘.success’) end end
◆知識
admin_login.html.erb内に<%= csrf_meta_tags %>の記述がないとInvalid Authentication Tokenエラーが出る。
同様に、admin_application.html.erb内に<%= csrf_meta_tags %>の記述がないと、destroyアクションが正しく機能しない。
Rails-ujsをマニフェストファイルで読み込んでいないと、postリクエストやdeleteリクエストが正しく動作せず、全てgetリクエストになってしまう。
Rails-ujsの役割については下記の記事を参考にさせていただきました
Rails学習者にrails-ujsの動作説明したら感動された話 - INODEVLOG