【rails】rspecでcallbackにハメられたお話
callbackとは
DBにデータを保存する前やバリデーションをかける前に処理を行うこと。
基本的にmodelの中に書かれる。
callbackにはcontrollerで持たせる必要はないが、保存処理などを行う前に必要な処理をまとめて書いておく。
実際の例
まずはOrderModel
を書いてみる。
大体こんな書きっぷりになるはず。
class Order < ApplicationRecord before_save: set_status, set_applied_at enum status: { applied: 0, canceled: 1, modified: 2 } def set_status status = 0 end def set_applied_at applied_at = Time.current end # この下にschemaが書かれる end
その時のOrdersController
は大体こんな書きっぷり。
class OrderController < ApplicationController def create @order = Order.new(order_params) @order.save end private def order_params params.require(:order).permit(:name, :phone_number, :email) end end
callbackの分割
callbackにもたせたいメソッドが増えてくるとmodelが見辛くなるのでmoduleに分割しておく。
上の例を分割するとこんな感じ。また、statusの項目を少し増やしてみる。
まずはOrderModel
から。
class Order < ApplicationRecord include OrderCallback enum status: { applied: 0, canceled: 1, modified: 2, in_progress: 3, auto_canceled: 4 } # この下にschemaが書かれる end
続いて分割したcallbackをOrderCallback
moduleで記載する。
module OrderCallback include do before_save: set_status, :set_applied_at end def set_status status = phone_number.present? ? 0 : 3 end def set_applied_at applied_at = (status = 0) ? Time.current : "" end end
今回はphone_numberが記入されていたらstatusをapplied、それ以外はin_progressとした。
さらにstatusによってcreateで描画するviewの出し分けを設定しておく。
class OrderController < ApplicationController before_action :check_status def create @order = Order.new(order_params) @order.save # appliedとin_progressというviewがあるとする render @order.status = 0 ? "applied" : "in_progress" end def show end private def check_status @order = Order.find(id) case status when 3 redirect_to order_show_path else redirect_to order_index_path end end def order_params params.require(:order).permit(:name, :phone_number, :email) end end
ハマりポイント、skip_callback
と、ここまではいたって普通なこと。ハマる部分は見あたらない。
(実はcallbackに記載した設定を忘れてハマることも多々あるが…)
ハマったのはrspecで使うskip_callback
だった。
今回のメソッドの中に、statusをauto_canceledにするものがないので強制的に設定してあげる必要がある。
しかし、phone_numberを空にしてupdateかけるとbefore_save
でset_status
が流れてしまいstatusはin_progressになってしまう。
そこで使うのがskip_callbackメソッド
。
書き方はこんな感じ。
describe OrderController, type: :controller do let(:order) do Factorybot.create(:order, { name: "hoge" phone_number : "090-0000-0000" email: "hoge@hoge.com" }) end describe "orderのstatusが" do context "in_progress" do order.update(phone_number: "") get :show expect(response).to redirect_to order_show_path end context "auto_canceled" do # 引数はアクション(save, validation)、タイミング(before, after)、メソッド名の順番で記載 order.skip_callback(:save, :before, :set_status) order.update(phone_number: "", status: 4) order.set_callback(:save, :before, :set_status) expect(response).to redirect_to order_index_path end end end
set_callback
の後でbinding.pryをかけてorderを見ればstatusがauto_canceledになっているのがわかる。
なので、本来はここでもハマることはない。
ヤサゴリがハマったのは別のrspecテスト
現場では山のようなテストコードが存在しており、rspecを全て流すとそこそこの時間がかる。
そして、そのrspecが流れる順番は上から順番とは限らない。
実際、ヤサゴリがハマったのは全く別のfeature_specだった。
skip_callback
の設定が他のfeature_specに影響を及ぼしており、そのせいでテストがコケていたのだった。
skip_callbackは極力使わない方針で
テストの量が多いと、流れる順番がわからないのでskip_callback
がどこで悪さをするかわからない。
また、今回通ったとしても次に修正を加えた時に悪さを起こす可能性が否めないので、skip_callback
は使わないことにした。
今回のコードでいうとset_status
に1行追加することで回避。
def set_status return if status == 4 status = phone_number.present? ? 0: 3 end
というか、1行追加するだけで回避できるなら最初からそっちにせいという話でもある…