FactoryBotにおける関連の扱いと、factory_bot-with gemを作った話
本記事は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]、頻出のカスタマイズをtraitやtransientを活用して定義することで、多くのケースで簡潔なオブジェクト生成の記述を可能にします。
# 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>
-
build(:author, :with_post)
にて- 関連
post
の解決のためにbuild(:post)
される
- 関連
-
build(:post)
にて- 関連
author
の解決のためにbuild(:author)
される
- 関連
-
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によって、 build
や create
といったファクトリメソッドの振る舞いに調整が加えられます。[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で生成する必要がある
- ex. この記事における
-
:with_post
のようなファクトリ自身が提供している抽象と比べ、具象的な記述になる- ファクトリの利用側が関連のオブジェクトにも関心を持つ、ということで一定by designである
- FactoryBotの一部internal APIに依存している
まとめ
経験上、FactoryBotのファクトリ定義はアドホックな拡張がされがちだと感じています。特に関連はそれぞれのファクトリが過剰に関心を寄せがちで、結果ファクトリの肥大化に繋がりがちです。ファクトリ定義は、快適に、容易に壊れないテストを記述する上で重要なツールの一つなので、ファクトリごとの関心事を意識して大事に扱っていきたいですね。
Discussion