【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をOrderCallbackmoduleで記載する。

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_saveset_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行追加するだけで回避できるなら最初からそっちにせいという話でもある…