ActiveRecordで子のアソシエーションが必須の場合のFactoryBotの使い方いろいろ

社内ドキュメントに書いたものの転載。

前提

猫と猫の脚というモデルがあり、猫は脚が一本以上は必要というバリデーションがされているとき、

class Cat < ApplicationRecord
  has_many :cat_legs, dependent: :destroy
  validates :cat_legs, presence: true
end

class CatLeg < ApplicationRecord
  belongs_to :cat
end

素直にFactoryを書くとこうだが、

FactoryBot.define do
  factory :cat do
    name { 'たま' }
    age { 5 }
  end
  factory :cat_leg do
    nail { true }
    association :cat # 書いても意味がない。先にcatを作ろうとするため、下記の通り、RecordInvalidになる
  end
end

これらは create では使えない。

pry(main)> FactoryBot.create(:cat)
ActiveRecord::RecordInvalid: Validation failed: Cat legs can't be blank
from /usr/local/bundle/gems/activerecord-6.0.3.3/lib/active_record/validations.rb:80:in `raise_validation_error'
pry(main)> FactoryBot.create(:cat_leg)
ActiveRecord::RecordInvalid: Validation failed: Cat legs can't be blank
from /usr/local/bundle/gems/activerecord-6.0.3.3/lib/active_record/validations.rb:80:in `raise_validation_error'

CatとCatLegは同時に保存されないとバリデーションを通過できない。

ファクトリー関係なく保存する方法

肝は、CatLegの方はsaveせずにbuildだけにしておいて、saveはCatの方で一回でやるということ。 これで、単一のトランザクションでINSERTされる。

irb(main):006:0> c = Cat.new
=> #<Cat id: nil, created_at: nil, updated_at: nil>
irb(main):007:0> c.cat_legs.build
=> #<CatLeg id: nil, cat_id: nil, created_at: nil, updated_at: nil>
irb(main):008:0> c.cat_legs.build
=> #<CatLeg id: nil, cat_id: nil, created_at: nil, updated_at: nil>
irb(main):009:0> c.save
   (0.1ms)  begin transaction
  Cat Create (28.5ms)  INSERT INTO "cats" ("created_at", "updated_at") VALUES (?, ?)  [["created_at", "2021-06-16 08:13:51.866786"], ["updated_at", "2021-06-16 08:13:51.866786"]]
  CatLeg Create (9.5ms)  INSERT INTO "cat_legs" ("cat_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["cat_id", 12], ["created_at", "2021-06-16 08:13:51.905388"], ["updated_at", "2021-06-16 08:13:51.905388"]]
  CatLeg Create (0.3ms)  INSERT INTO "cat_legs" ("cat_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["cat_id", 12], ["created_at", "2021-06-16 08:13:51.919341"], ["updated_at", "2021-06-16 08:13:51.919341"]]
   (40.5ms)  commit transaction

1行でやりたいならこういうのでもいい。

irb(main):012:0> c = Cat.new(cat_legs: [CatLeg.new, CatLeg.new])
=> #<Cat id: nil, created_at: nil, updated_at: nil>
irb(main):013:0> c.save!
   (0.1ms)  begin transaction
  Cat Create (25.2ms)  INSERT INTO "cats" ("created_at", "updated_at") VALUES (?, ?)  [["created_at", "2021-06-16 08:26:17.387995"], ["updated_at", "2021-06-16 08:26:17.387995"]]
  CatLeg Create (8.7ms)  INSERT INTO "cat_legs" ("cat_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["cat_id", 14], ["created_at", "2021-06-16 08:26:17.427883"], ["updated_at", "2021-06-16 08:26:17.427883"]]
  CatLeg Create (0.6ms)  INSERT INTO "cat_legs" ("cat_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["cat_id", 14], ["created_at", "2021-06-16 08:26:17.440913"], ["updated_at", "2021-06-16 08:26:17.440913"]]
   (57.2ms)  commit transaction
=> true

Catモデルに accepts_nested_attributes_for :cat_legs があれば、もちろん、cat_legs_attributes で一発で渡せる。

irb(main):001:0> Cat.create!(cat_legs_attributes: [{}])
   (1.2ms)  SELECT sqlite_version(*)
   (0.1ms)  begin transaction
  Cat Create (25.7ms)  INSERT INTO "cats" ("created_at", "updated_at") VALUES (?, ?)  [["created_at", "2021-06-16 08:02:22.525263"], ["updated_at", "2021-06-16 08:02:22.525263"]]
  CatLeg Create (10.4ms)  INSERT INTO "cat_legs" ("cat_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["cat_id", 10], ["created_at", "2021-06-16 08:02:22.561846"], ["updated_at", "2021-06-16 08:02:22.561846"]]
   (35.8ms)  commit transaction
=> #<Cat id: 10, created_at: "2021-06-16 08:02:22", updated_at: "2021-06-16 08:02:22">

解法

ストラテジーごとにやり方はいくつかある。上から推奨順に書いている。

注意

以下のやり方は create_list する場合には意図した通りになりません。

github.com

cat_legは同じインスタンスが使いまわされてしまうので、最後のcatにしかcalt_legsが入っていないことになってしまいます。複数作りたい場合は、諦めて素直にcreateを複数実行しましょう。

    cats = [
      FactoryBot.create(:cat, tags: [FactoryBot.build(:cat_leg)]),
      FactoryBot.create(:cat, tags: [FactoryBot.build(:cat_leg)])
    ]

createのパラメータとして子のアソシエーションも渡す

いろいろ検討したけど、いつものやり方で普通にできた。

FactoryBot.create(:cat, cat_legs: FactoryBot.build_list(:cat_leg, 4))

これはaccepts_nested_attributes_forの有無に拘らず、単一のトランザクションで処理される。

irb(main):003:0> FactoryBot.create(:cat, cat_legs: FactoryBot.build_list(:cat_leg, 4))
   (0.1ms)  begin transaction
  Cat Create (34.5ms)  INSERT INTO "cats" ("created_at", "updated_at") VALUES (?, ?)  [["created_at", "2021-06-16 07:50:50.484299"], ["updated_at", "2021-06-16 07:50:50.484299"]]
  CatLeg Create (14.6ms)  INSERT INTO "cat_legs" ("cat_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["cat_id", 7], ["created_at", "2021-06-16 07:50:50.527524"], ["updated_at", "2021-06-16 07:50:50.527524"]]
  CatLeg Create (0.1ms)  INSERT INTO "cat_legs" ("cat_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["cat_id", 7], ["created_at", "2021-06-16 07:50:50.548179"], ["updated_at", "2021-06-16 07:50:50.548179"]]
  CatLeg Create (0.3ms)  INSERT INTO "cat_legs" ("cat_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["cat_id", 7], ["created_at", "2021-06-16 07:50:50.551906"], ["updated_at", "2021-06-16 07:50:50.551906"]]
  CatLeg Create (0.2ms)  INSERT INTO "cat_legs" ("cat_id", "created_at", "updated_at") VALUES (?, ?, ?)  [["cat_id", 7], ["created_at", "2021-06-16 07:50:50.555688"], ["updated_at", "2021-06-16 07:50:50.555688"]]
   (37.0ms)  commit transaction
=> #<Cat id: 7, created_at: "2021-06-16 07:50:50", updated_at: "2021-06-16 07:50:50">

build して明示的にsaveする

cat = FactoryBot.build(:cat, cat_legs: FactoryBot.build_list(:cat_leg, 4))
cat.save!

以下のように2行に分けてやるとcatの方で関連を認識できないのでバリデーションを通過できないので注意。

cat = FactoryBot.build(:cat)
FactoryBot.build_list(:cat_leg, 4, cat: cat)
cat.save!

よく使う場合はfactoryと同じファイルでこんなメソッドを定義してもいい。

def cat_with_legs_by_build(legs_count: 4)
  cat = FactoryBot.build(:cat, cat_legs: FactoryBot.build_list(:cat_leg, legs_count))
  cat.save!
  cat
end

(accepts_nested_attributes_forの場合) attributes_for を使う

accepts_nested_attributes_for なら子の属性も同時に渡せるのでこうもやれる。やってることは上記の build のやり方と同じなので、 build の方がコードがシンプルになりやすいのでbuildの方がいいと思う。

cat = Cat.new(FactoryBot.attributes_for(:cat).merge(cat_legs_attributes: FactoryBot.attributes_for_list(:cat_leg, 4)))
cat.save!

(非推奨) :skip_validate を使う

create(:cat, :skip_validate) で保存はできるが、あとでvalidかどうかの確認をしないといけないので、上記のやり方でカバーできるのであれば使わない方がいい。

ダメだったやり方

has_many association のやり方で

def cat_with_legs(legs_count: 5)
  FactoryBot.create(:cat) do |user|
    FactoryBot.create_list(:cat_leg, legs_count, cat: cat)
  end
end

こんな感じでいけるかと思ったけど、やはり親のsaveの方が先に実行されてしまうようでバリデーションを通過できなかった。