🏭

FactoryBotにおける関連の扱いと、factory_bot-with gemを作った話

2024/12/15に公開

本記事はRuby on Rails Advent Calendar 2024の15日目の記事です。

導入

Ruby on Railsによるアプリケーション開発にあたって、FactoryBotを使用されている方は多いかと思います。本記事ではFactoryBotにおける関連(association)について掘り下げ、その過程で作成したfactory_bot-with gemというgemの紹介をします。

FactoryBot全般については、公式のfactory_bot Bookで網羅的に解説されているほか、ベストプラクティスについてもKaigi on Rails 2020のFactoryBot the Right Wayなどが参考になります。

FactoryBotと一つのオブジェクト

FactoryBotは一つのRubyオブジェクトを生成するという仕事をとても得意とします。

デフォルトではオブジェクトがvalidとなるような最小の属性を定義しつつ [1]、頻出のカスタマイズをtraittransientを活用して定義することで、多くのケースで簡潔なオブジェクト生成の記述を可能にします。

# BAD: FactoryBotを使用しているが、多くの属性を明示的に指定する必要がある
build(:user, name: "John Doe", email: "john+due@example.com", age: 30, job: "Middle School Teacher")

# GOOD: ファクトリによって、興味がない属性にも適当な値が自動で設定される
build(:user)            # 何も指定しなければ最小のvalidなオブジェクトが生成される
build(:user, :teacher)  # よく使用するカスタマイズもtraitで簡潔に利用できる

# GOOD 例のためのファクトリ例 w/ faker gem
FactoryBot.define do
  factory :user do
    # 最小の必須の属性はデフォルトで与える
    name { Faker::Name.name }
    email { Faker::Internet.email }
    age { Random.rand(12..80) }
    job { Faker::Job.title }
    # ...

    trait :teacher do
     job { "#{EDUCATION_LEVELS.sample} Teacher" }
     # ...
    end
  end
end

FactoryBotと複数のオブジェクト

関連について、必須の関連と、必須の関連に対応する逆方向の関連の2つに分けて見ていきます。

必須の関連

往々にして、オブジェクトには必須の関連があり、オブジェクト単体ではvalidでないことがあります。ActiveRecordで特に頻出なパターンは、optionalでない belongs_to 関連...従属があるパターンです。FactoryBotはこのような必須の関連を扱うことも比較的得意とします。

FactoryBotのassocation[2]定義を用いることで、その定義に沿った関連オブジェクトを同じbuild strategyで生成させることができます。その関連オブジェクトもまたassociationを持つなら、連鎖的にオブジェクトが生成されます。

# 必須の関連が存在するActiveRecordモデル例
class Post < ApplicationRecord
  belongs_to :author
end

class Author < ApplicationRecord
  has_one :post, dependent: :destroy  # 逆方向の関連については後述
end

FactoryBot.define do
  factory :post do
    association :author  # post は author と関連する
  end

  factory :author
end

# postを生成すると、関連するauthorもbuildされる
post = build(:post)
# ここで post.author = build(:author) 相当のbuildが走る
post.author.present? #=> true

必須の関連に対応する逆方向の関連

このような必須の関連を扱うことをFactoryBotは得意とする一方で、対応する逆方向の関連を扱うときは少し注意が必要になります。

まず、以下のようなファクトリ定義は適切ではありません。

factory :author do
  # ※「authorに従属するpostの生成」はauthorにとって最小ではないのでtraitにする
  trait :with_post do
    association :post
  end
end

factory :post do
  association :author
end

このファクトリを、 after(:build) { puts "#{_1.class}:#{_1.object_id}" } のような適当なコールバックを設定して試すと、以下のように Author オブジェクトが2度生成されていることがわかります。

irb> FactoryBot.build(:author, :with_post)
Author:30840
Post:30860
Author:30880
=> #<Author:0x00007fa227da5a68 id: nil, created_at: nil, updated_at: nil>
  1. build(:author, :with_post) にて
    • 関連 post の解決のために build(:post) される
  2. build(:post) にて
    • 関連 author の解決のために build(:author) される
  3. build(:author) にて
    • このオブジェクトの生成は :with_post でないため、さらなる関連のbuildはされない

のようなステップで、不要な Author オブジェクトの生成が走ります。我々が望む振る舞いは、2ステップ目の build(:post)build(:post, author: (ステップ1で生成したAuthorオブジェクト)) のように呼び出されることです。そうなればauthorが明示されているので、3ステップ目のbuildは走りません。

このような要求はコールバック関連のinline definitionを使用することで満たすことができます。いずれもファクトリが生成したオブジェクト自身を参照することができます。

# コールバックを使用する例
factory :author do
  trait :with_post do
    after(:build) do |author|
      # NOTE: create したとき post も save されるかどうかはモデルの関連の autosave に依存してしまう
      build(:post, author:)
    end
  end
end
# 関連のinline definitionを使用する例
factory :author do
  trait :with_post do
    # 属性の値を計算するブロック中でも association ヘルパーが使用でき、また生成後のオブジェクトを instance で参照できる
    post { association :post, author: instance }
  end
end

より自由度が高いのはコールバックですが、基本的にはinline definitionを用いるのが良いでしょう。build strategyを尊重してくれて、より宣言的に記述できます。[3]

関連のカスタマイズ、Fat factories

ここまでで、関連も含めてvalidなオブジェクトを生成できるファクトリ定義の方法を一通り抑えました。ここから更に、関連オブジェクトもカスタマイズできるようにするにはどうしたらよいでしょうか。

factory :author do
  trait :with_post do
    post { association :post, author: instance }
  end
end

factory :post do
  association :author

  # たとえばこのようなtraitがあるとする
  trait :new do
    # ...
  end
end

build(:author, :with_post) # ここで post を :new にしたい

まず思いつくのは、関連先と同様にtraitを用いる手法でしょうか。

factory :author do
  trait :with_post do
    post { association :post, author: instance }
  end

  trait :with_new_post do
    post { association :post, :new, author: instance }
  end
end

build(:author, :with_new_post)

この手法は単純な例ではうまくいきますが、関連先のtraitが今後増えていくことを考えると厳しいものがあります。例えば post ファクトリのtraitが10個増えたとして、 author ファクトリも合わせてtraitを10個増やすでしょうか。さらに、traitは組み合わせることができますが、この方法ではその組み合わせを表現できません。

factory :author do
  trait :with_post do
    post { association :post, author: instance }
  end

  trait :with_new_post do
    post { association :post, :new, author: instance }
  end

  trait :with_experimental_post do
    post { association :post, :experimental, author: instance }
  end
end

build(:author, :with_new_post, :with_experimental_post)
# としたときの post は build(:post, :new, :experimental, ...) 相当とはならない:
# with_new_post の post 定義は with_experimental_post の post 定義で上書きされる

ここで、transient attributeを用いることで、関連の定義への入力を動的に取る、という手法が考えられます。

factory :author do
  trait :with_post do
    transient do
      post_traits { [] }
    end

    post { association :post, *post_traits, author: instance }
  end
end

build(:author, :with_post, post_traits: %i[new experimental])

ひとまず目的は達成できました。さて、さらに post に特定の属性も与えたくなったので、 author ファクトリで post 関連への属性の指定もサポートするとしましょう。同じくtransient attributeを用います。

factory :author do
  trait :with_post do
    transient do
      post_traits { [] }
      post_attrs { {} }
    end

    post { association :post, *post_traits, author: instance, **post_attrs }
  end
end

build(:author, :with_post, post_traits: %i[new experimental], post_attrs: { archived: true })

...ここまでいくと関連のカスタマイズもとても柔軟に行えますが、このようなことを無造作にやっていると、すぐファクトリ定義が肥大化してしまいますね。すべての関連でこれを行うのは大変ということで、使うところだけの定義に留めようとしても、新たなカスタマイズ要件が発生するたび、不便さとファクトリの肥大化とでのトレードオフに悩みがちです。

結局のところ、この章のスタート地点の動機はそのまま、 ファクトリが関連オブジェクトのカスタマイズまでを関心事に含めて吸収しようとしてしまっている ところに避けられない難しさがあります。ファクトリの利用者が関連オブジェクトまでカスタマイズしたいときは、つまり利用者は関連オブジェクトにも関心があるので、ファクトリで無理にカバーせず、以下のように素朴に記述するのが無難なケースが多いのではないかと思います。

build(:author) do |author|
  build(:post, :new, :experimental, archived: true, author:)
end

このようにファクトリでの過剰なサポートを控えることで、自然とそれぞれのファクトリは関心事を「生成対象のオブジェクト自身のカスタマイズ」に絞ることができます

factory_bot-with: 関連オブジェクトのための記法の導入

とはいえ、このような関連オブジェクトの生成はしばしば必要となり、頻度に対してこのオブジェクト生成の記述は少々冗長です。ということで、このパターンを書きやすくするgemとしてfactory_bot-withというgemを作成してみました。このgemを導入すると、以下のように関連オブジェクト生成の記述を書き換えることができます。

# Before
build(:author) do |author|
  build(:post, :new, :experimental, archived: true, author:)
end

# After
build(:author, with(:post, :new, :experimental, archived: true))

より複雑な例、たとえば多段の関連オブジェクト生成も以下のように記述できます。 (build(:factory) の代わりに build.factory と記述できるショートカット記法も使用しています)

# Before
create(:blog) do |blog|
  create(:article, blog:) { |article| create(:comment, article:) }
  create(:article, blog:) { |article| create_list(:comment, 3, article:) }
end

# After
create.blog(with.article(with.comment), with.article(with_list.comment(3)))

このgemはどのように動作するのでしょうか? このgemはまず、 with というオペレータを導入します。 with の呼び出し方はFactoryBotのファクトリメソッドの呼び出し方とまったく同じで、 with の部分が後ほど実際のbuild strategyに置き換わって呼び出される、ファクトリメソッド呼び出しのためのテンプレートとなります。

次にこのgemによって、 buildcreate といったファクトリメソッドの振る舞いに調整が加えられます。[4] ファクトリメソッドはまず、オブジェクト生成の前段で引数中の with オペレータを収集するようになります。次にファクトリメソッドはオブジェクトの生成後、ファクトリ中の関連についての定義に基づいて、収集された with オペレータごとに関連オブジェクトを生成していきます。

詳細な機能はリポジトリのREADMEをご参照ください。また、specと対応するテスト用ファクトリを見ると with オペレータ使用時の関連の自動解決についての振る舞いが知れるかと思います。

このgemを使用することで、これまでそれぞれのファクトリがカバーしていた仕事の穴を塞ぐことができ、それによって :with_post のようなtraitを取り除いていけることが期待できます。しかしながら、 このgemはまだexperimentalです。 APIデザイン上の課題や改善案、現状解決できないがこのgemで解決できるべきユースケースなどがありましたらご連絡いただけると幸いです。

考えているものの解決が難しそうな課題もあります
  • with 命名がActiveSupportの Object#with と被ってる
    • エイリアスを提供するか、または別の良い命名がないか...?
  • 双方向に必須な関連の扱いが困難
    • ex. この記事における has_one :post にも presence: true なバリデーションがある場合など
    • これは従来のFactoryBotも同様の課題を持っていて、 build.author(with.post).tap { _1.save! } のように先にすべての関連を build build strategyで生成する必要がある
  • :with_post のようなファクトリ自身が提供している抽象と比べ、具象的な記述になる
    • ファクトリの利用側が関連のオブジェクトにも関心を持つ、ということで一定by designである
  • FactoryBotの一部internal APIに依存している

まとめ

経験上、FactoryBotのファクトリ定義はアドホックな拡張がされがちだと感じています。特に関連はそれぞれのファクトリが過剰に関心を寄せがちで、結果ファクトリの肥大化に繋がりがちです。ファクトリ定義は、快適に、容易に壊れないテストを記述する上で重要なツールの一つなので、ファクトリごとの関心事を意識して大事に扱っていきたいですね。

脚注
  1. ファクトリ定義についてのベストプラクティスを参照 ↩︎

  2. 本記事ではファクトリでの関連の定義にexplicit definitionを用いますが、implicit definitionでも問題ありません ↩︎

  3. 一方inline definitionでは、 attributes_forinitialize_with とのかみ合わせが悪い、属性のブロック評価時点では instance の他の属性は初期化途中であることなどに注意する必要があります ↩︎

  4. FactoryBot::Syntax::Methods の代わりにincludeする FactoryBot::With::Methods を提供することで置き換えています ↩︎

Discussion