FactoryBotでコールバックをスキップする方法

FactoryBotでコールバックをスキップする方法

10分で読めるテック

RSpec でテストを書いていて、assert しているデータの内容は合っているのに ID だけがずれているケースに遭遇しました。原因を追っていくと、after_create コールバックが先に別のレコードを作っていたのが原因でした。

ActiveRecord のコールバックとは

ActiveRecord のコールバックは、オブジェクトの作成・更新・削除といったライフサイクルイベントをフックにして処理を実行する仕組みです。before_createafter_createafter_commit など、保存フローの各ポイントに処理を差し込めます。

例えばユーザーが登録されたタイミングでメールを送りたい場合、レコード保存をフックとして after_create に処理を書きます。

class User < ApplicationRecord
  after_create :send_mail

  private

  def send_mail
    # メール送信処理
  rescue => e
    errors.add(:base, e.message)
    throw :abort
  end
end

アプリケーション本体では便利な仕組みですが、RSpec でテストを実行するときに予期しない振る舞いの原因になることがあります。

ActiveRecord はコールバックの定義をクラス変数で保持しています。User._create_callbacks などで確認でき、コールバックが登録されている順序と種類を見ることができます。skip_callbackset_callback はこのクラス変数を直接操作するAPIです。

# テストコンソールで確認できる
User._create_callbacks.map { |cb| [cb.filter, cb.kind] }
# => [[:send_mail, :after]]

テスト時にコールバックをスキップしたい

例えば上記のユーザーを保存する処理を確認したい場合で、テスト環境にはメール送信の仕組みが整っていないと、send_mail が例外を投げてユーザーの保存自体が失敗します。また、after_create で別のレコードを作る処理が入っていると、意図していない ID 採番が起きてアサーションがずれます。

自分が遭遇した症状はまさにこれでした。テストでは user.id を直接アサートするつもりだったのに、コールバックが先に Profile レコードを作ってしまい、シーケンスが進んでしまうケースです。

# コールバックの影響を受けるケース
class User < ApplicationRecord
  after_create :create_profile

  private

  def create_profile
    # これが ID 採番をずらす
    Profile.create!(user: self)
  end
end

こういった場面では skip_callback を使うと、次に set_callback が呼ばれるまでの間、指定したコールバック処理をスキップできます。

skip_callback の第一引数はライフサイクルの種類(:create:update:destroy など)、第二引数はタイミング(:before:after:around)、第三引数はメソッド名のシンボルです。

# コールバックをスキップ
User.skip_callback(:create, :after, :send_mail)

# 元に戻す
User.set_callback(:create, :after, :send_mail)

ただし、この2つをそのままテストコードに直書きすると、呼び忘れやテスト実行順による影響が出ます。FactoryBot のフックを使えばスコープを明確に管理できます。

FactoryBot での書き方

FactoryBot のファクトリ定義に before(:create)after(:create) を組み合わせて書きます。

FactoryBot.define do
  factory :user do
    name { Faker::Name.name }
    auth { 'admin' }
    email { 'sample@example.com' }

    before(:create) { User.skip_callback(:create, :after, :send_mail) }
    after(:create)  { User.set_callback(:create, :after, :send_mail) }
  end
end

before(:create) でコールバックを無効にし、after(:create) で元に戻しています。set_callback で元に戻す処理がないと、そのテストスイート以降の他のテストにも影響が出てしまうため、必ずペアで書くようにします。

このパターンを使うことで、send_mail そのものをテストするケース以外では、コールバックの副作用を気にせずにファクトリを使えるようになります。

複数コールバックをまとめてスキップする

コールバックが複数ある場合は、配列でまとめて書けます。

FactoryBot.define do
  factory :user do
    name { Faker::Name.name }
    email { 'sample@example.com' }

    before(:create) do
      User.skip_callback(:create, :after, :send_mail)
      User.skip_callback(:create, :after, :create_profile)
    end
    after(:create) do
      User.set_callback(:create, :after, :send_mail)
      User.set_callback(:create, :after, :create_profile)
    end
  end
end

trait でコールバックの有無を選択できるようにする

「このテストではコールバックが必要、あのテストでは不要」という場面では、trait を使って切り替えられるようにする方法が便利です。

FactoryBot.define do
  factory :user do
    name { Faker::Name.name }
    email { 'sample@example.com' }

    trait :skip_callbacks do
      before(:create) { User.skip_callback(:create, :after, :send_mail) }
      after(:create)  { User.set_callback(:create, :after, :send_mail) }
    end
  end
end

テストコード側では以下のように使い分けます。

# コールバックを実行しない
let(:user) { create(:user, :skip_callbacks) }

# コールバックを実行する(デフォルト)
let(:user) { create(:user) }

デフォルトはコールバックあり・必要なときだけ trait で無効化するほうが、テストの意図が明確になります。

before(:build)before(:create) の違いに注意

FactoryBot には buildcreate という2つの生成方法があります。build はデータベースに保存せずオブジェクトだけ作り、create は保存まで行います。コールバックのスキップは before(:create) / after(:create) に書く必要があります。before(:build) に書いても create 時のコールバックには影響しないため、フックの種類は正確に指定してください。

skip_callback がスレッドセーフでない点

skip_callbackset_callback はクラスレベルの状態を変更します。RSpec を並列実行(parallel_tests など)している環境では、片方のスレッドがコールバックをスキップしている間にもう片方のスレッドがコールバックの有無を前提にしたアサーションを実行すると、テスト結果が不安定になることがあります。

並列実行環境では、コールバックを無効にしたいモデルのインスタンスを直接生成して save! する方式や、モデルのコールバック対象メソッドをスタブで差し替える方法(allow_any_instance_of(User).to receive(:send_mail))の方が安全なケースがあります。

# スタブを使う代替案
before do
  allow_any_instance_of(User).to receive(:send_mail)
end

スタブはクラス状態を変えないため、並列テストでも安全です。ただし、コールバック内で他のモデルを作るような副作用を防ぎたい場合は、今回の skip_callback パターンの方が根本から止められます。ユースケースに合わせて使い分けてください。

バージョン・公式リファレンス

skip_callbackset_callbackActiveSupport::Callbacks::ClassMethods モジュールで定義されており、ActiveRecord を含む Rails の各コンポーネントから利用できます。Rails の組み込みコールバック機構として長く提供されているAPIです。お使いの Rails バージョンに合わせて、下記の公式ドキュメントで挙動を確認してください。

参考

質問・リクエストを送る

記事についての質問や、取り上げてほしいテーマがあればお気軽にどうぞ。いただいた質問はブログ記事として回答し、Q&Aページで公開することがあります。

このサイトについて

井上 周(Amane Inoue)の個人ブログです。技術・読書・ドラマ・旅・大学生活のことを書いています。