FactoryBotベストプラクティス

has_many関連の定義方法

FactoryBotの定義チートシート#has_manyアソシエーションを参照。

FactoryBotの定義ファイル内でcreatebuildを使わずassociationを使う

createは保存不要な場合でも常にDBに保存されてしまうし、buildを使った場合でもattributes_forメソッドやbuild_stubbedメソッドの挙動がおかしくなる。

FactoryBot.define do
  factory :user do
    # attributes_forの戻り値に
    # buildメソッドの戻り値(つまりActiveRecordのインスタンス)
    # が入ってきてしまう
    # build_stubbedではこれらのインスタンスのIDなどは設定されない
    blogs { build_list(:blog, 5) }
    profile { build(:profile) }
  end
end

したがって、FactoryBotの定義ファイル内で参照するならassociationを使った方が良い。associationならattributes_forメソッドやbuild_stubbedメソッドでも適切な結果になる。

FactoryBot.define do
  factory :user do
    blogs { Array.new(5) { association(:blog, user: instance) }.compact }
    profile { association(:profile) }
  end
end

また、rubocop-factory_botを入れているならFactoryBot/FactoryAssociationWithStrategyに警告される。

ディスクアクセスを最小化する

関連の定義もそうだが、ディスクアクセスが発生すれば、そのぶんパフォーマンスが低下することになる。したがって、ファイルからデータを読み込むようなfactoryは極力インスタンス化させないようにしなければならない。

FactoryBot.define do
  factory :user do
    trait :with_big_data do
      big_data { File.read(Rails.root / 'spec/fixtures/files/user_big_data') }
    end
  end
end

上記のようにtraitを使って、必要な場合にのみ参照させるか、factoryを分けてしまうなどの対応を取った方が良い。

traitはテスト側から参照しない

テスト側からtraitを参照しない方が良い。参照したとしても、レゴブロックのようにtraitを組み合わせるのではなく、すでにあるfactoryをチューニングするスイッチとして使った方が良い。

例えば、以下のようなfactoryを考える。

FactoryBot.define do
  factory :user do
    login_name { 'account' }
    premium { false }
    trait :normal_user
    trait :premium_user do
      premium { true }
    end

    trait :user_has_blogs do
      blogs { Array.new(blogs_count) { association(:blog, user: instance) }.compact }
    end
  end
end

このfactoryを利用するなら、create(:user, :premium_user, :user_has_blogs, login_name: 'suzuki')のようになる。もし、user_has_blogsnormal_user用のtraitでpremium_useruser_has_blogsの組み合わせがデータ不整合を起こしていたとしても、テストが失敗しないかぎりは間違いに気づくことは難しいだろう。

このように、traitをレゴブロックのように組み合わせる設計をしてしまうと、factoryを利用する側に組み合わせの整合性の責任を負わせることになる。そして、factoryはテストのあらゆる場所から利用されることを考えれば、まずい設計であることは明らかである。

したがって、整合性の責任はfactoryの定義内で負うべきである。factory :normal_userfactory :normal_user_has_blogsのように整合性を持つ単位でfactoryを用意した方が良い。

traitを定義するとしても、factoryから参照するだけでテスト側から参照しないようにするか、整合性に影響しないちょっとした設定を変更するスイッチとして利用した方が良い。

factoryを作る単位

DDD的に言えば、factory :foo { ... }とした場合のfooはAggregateあるいはEntityの名前として、定義内容もAggregateを構築するのに必要なデータとそれらのデータをテーブルに保存するのに必要なデータを分けて記載するのが良いと考えている。

要はこういうことである。

# 履歴書テーブルかつ履歴書Aggregateのためのfactory
factory :resume do
  # 履歴書データを保存するための前提となるアソシエーション
  user { association(:user, resume: instance) }

  # 履歴書Aggregateを構築するのに必要な情報
  name { '鈴木太郎' }
  address { '東京都渋谷区なんたら 1-2-3' }
  academic_backgrounds {  Array.new(5) { association(:academic_backgrounds, resume: instance) }.compact }
end

# 学歴テーブルかつ学歴Entityのためのfactory
factory :academic_backgrounds do
  # 学歴データを保存するための前提となるアソシエーション
  resume { association(:resume, academic_backgrounds: [instance]) }

  # 学歴Entityを構築するのに必要な情報
  entrance_date { '2020/04/01'.to_date }
  graduation_date { '2024/03/31'.to_date }
  school_name { 'どこそこ情報専門学校' }
end

Factory名に日本語を使う

Factory名には日本語を使える。変にわかりにくい長文英語を書くぐらいならこちらの方がわかりやすい可能性がある。

factory :履歴書, class: 'Resume' do
  factory :大卒履歴書 do
    # ...
  end

  factory :短大履歴書 do
    # ...
  end
end

これについては賛否あると思うが、常にシンボルでありRuboCopのNaming/AsciiIdentifiersに警告されない点、定義(factoryメソッド)も利用(buildメソッドなど)もFactoryBotであることが明らかで、git grep 'factory :ファクトリー名[ ,]' spec/factories/で一意に特定できることからありではないかと考えている。Trait名についても同様である。

なお、私個人は日本語変数名、クラス名には忌避感を持っている側だが、それでもなおFactory名を日本語にするのはありではないかと考えている。

Factory名からModelが何かわかるようにする

たまにFactory名がトリッキーでなんのModelのインスタンスを作るのか、Factory名からわからない場合がある。ひどいと別のModelを作ると誤読するようなケースもある。

なので、明確にfactory :大卒履歴書のように「履歴書」のファクトリであることがわかるようにするか、もっと直接的にfactory :大卒履歴書Resumeのようにしてしまう方法も考えられる。

データを用意するのではなくプロダクションコードで作る

これは思いつきレベルのアイディアで完全に詰め切れているわけではない。

createメソッドの場合にしか使えないが、プロダクションコードを実行した結果を再現したデータを用意するよりもプロダクションコードによってデータを作らせた方が安全である。実行した結果を再現したデータというのはMockを使ったテストと似たような物で、テストの妥当性が再現したデータが正当かどうかに依存してしまっている。

なので、例えばユーザーが非公開にしたブログというデータを用意したいのであれば、

factory :blog do
  factory :非公開ブログ do
    status { 'private' }
  end
end

とするよりも

factory :blog do
  factory :非公開ブログ do
    after(:create) do |blog|
      blog.to_private
    end
  end
end

とした方がよい。ただ、いうまでもなくbuildのケースに対応できておらず、次のようにしたくなるがafter(:build)コールバックはcreateメソッドを実行した場合にも実行されてしまうため目的を達成できない(FactoryBotで'private'を設定した後にto_privateを実行してしまう)。

# これでは目的は達成できない
factory :blog do
  factory :非公開ブログ do
    after(:build) do |blog|
      blog.status = 'private'
    end

    after(:create) do |blog|
      blog.to_private
    end
  end
end

色々調べてみたが、コールバックが実行したのがbuildなのかcreateなのか区別する方法はなく、グローバルなtransientを定義することもできないため、スマートなやり方はなさそうである(instance_variable_getで中に手を突っ込めばできそうではあるが、内部実装に依存した記述はするべきではない)。一番単純な回避方法はbuild用factoryとcreate用factoryを用意する方法である。

factory :blog do
  factory :build非公開ブログ do
    status { 'private' }
  end

  factory :create非公開ブログ do
    after(:create) do |blog|
      blog.to_private
    end
  end
end

あるいは、:biuld非公開ブログではなく:非公開ブログとして単体テストレベルではこちらを用いて、:create非公開ブログは結合テストレベルで用いることを意図して:integration非公開ブログというような名前にしても良いかもしれない。

欲を言えば、CIでattributes_for(:非公開ブログ)create(:integration非公開ブログ)の結果が一致していることを検証できると良いが、アソシエーション先も変更するようなプロダクションコードの場合もあるので、なかなか難しいだろうと思う。

FactoryBotの定義ファイルを実行可能ドキュメントとする

ここまで書いた内容を実践すると、Aggregateで使うデータが明らかになるし、プロダクションコードを実行するようになれば、実際のデータが生じるまでの処理の流れが明らかになる。

ApplicationServiceを定義していて、それを呼び出すようにしていれば、それは実態に即したユースケース記述となりうる。

DDDなんぞやらんよと思っても、Aggregateを特定処理を実行する際に必要なデータの最小セットと捉えれば有用性は伝わるだろうと思う。