Rails + factory_botで、sub factoryやtrait・callbackを使って関連データを生成してみた

Rails + RSpec + factory_bot にて、あるモデルの関連データを生成する方法を調べたところ、factory_botのGETTING_STARTEDにいろいろな方法が記載されていました。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md

そこで、GETTING_STARTEDの内容を素振りしてみたときのメモを残します。

 
目次

 

環境

 

テーブル構造

前回の記事同様、 AreaApple モデルを使います。

 
Area

class Area < ApplicationRecord
  has_many :apples
end

 
Apple

area に関連付けなくても良いとするため、

とします。

class Apple < ApplicationRecord
  belongs_to :area, optional: true
end

 

関連データを順次生成

各factoryを用意します。factoryの中では関連付けを定義しません。

# rspec/factories/apples.rb
FactoryBot.define do
  factory :apple do
  end
end

# rspec/factories/area.rb
FactoryBot.define do
  factory :area do
    name { '日本' }
  end
end

 
多対一の一側(area)を生成し、その値を多側(apple)へ渡して生成します。

context '順次生成' do
  let(:area) { create(:area) }
  let(:apple) { create(:apple, area: area) }

  it do
    expect(Apple.find(apple.id).area).to eq(area)
  end
end

 

多側のfactoryで、関連データを一度に生成 (Associations)

先ほど見たように、関連を順次生成する場合、必要な関連をletなどで定義しておく必要があります。

「一つのfactoryを実行したら、合わせて関連データも作成したい」場合を調べたところ、factory_botAssociations という機能を使えば良さそうでした。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#associations

Associations では

  • 関連を使う
  • factoryを使う

という方法があるため、それぞれ見ていきます。

 

関連を使った生成

関連を使う場合、

  • 暗黙的定義
  • 明示的定義
  • インライン定義

の3パターンの指定方法があるため、それぞれ見ていきます。

 

暗黙的定義

一側のfactory名(area)と、多側のモデルにある関連名(belongs_to)が同じ場合、暗黙的な関連が使えます。

例えば

# 一側のfactory
FactoryBot.define do
  factory :area do
    name { '日本' }
  end
end


# 多側のモデル
class Apple < ApplicationRecord
  belongs_to :area, optional: true
end

という関連の定義がモデルでされていたとします。

 
その場合、多側のfactoryで

FactoryBot.define do
  factory :apple do
    factory :apple_association_by_implicit do
      # Areaのfactory名(area)と、Appleモデルの関連名(belongs_to)が同じ場合、暗黙的な関連を使える
      area
    end
  end
end

のように定義できます。

 
これにより、テストコードでは create を1回使うだけで関連を生成できます。

context '暗黙的な定義' do
  before { create(:apple_association_by_implicit) }

  it do
    expect(Apple.last.area.name).to eq('日本')
  end
end

 

明示的定義

関連を明示的に定義するには、 association を使います。

factory :apple_association_by_explicit do
  association :area
end

 
テストコードは暗黙的な書き方のときと同様です。

context '明示的な定義' do
  before { create(:apple_association_by_explicit) }

  it do
    expect(Apple.last.area.name).to eq('日本')
  end
end

 

インライン定義

関連はインラインでも定義できます。

factory :apple_association_by_inline do
  area { association :area }
end

 
テストコードでの使い方は、他の関連定義と同様です。

context 'インライン定義' do
  before { create(:apple_association_by_inline) }

  it do
    expect(Apple.last.area.name).to eq('日本')
  end
end

 

factoryを使った生成

ここまでは関連を使った定義を見てきました。

ただ、関連の生成にはfactoryを使いたい場合があるかもしれません。

その場合、一側のfactoryを多側のfactoryに指定します。

例えば、一側であるAreaモデルのfactoryが以下のように定義されている場合に、多側であるAppleモデルのfactoryをどう定義すればよいか見ていきます。

# rspec/factories/areas.rb
FactoryBot.define do
  factory :area do
    name { '日本' }

    factory :aomori_area do
      name { '青森県' }
    end

    factory :nagano_area do
      name { '長野県' }
    end

    factory :iwate_area do
      name { '岩手県' }
    end
  end
end

 

暗黙的定義

関連 area の後ろに factory を指定します。

以下の例ではAreaモデルのfactory aomori_area を指定しています。

# rspec/factories/apples.rb
factory :apple_factory_by_implicit do
  area factory: :aomori_area
end

 
これで、関連の生成に aomori_area factoryが使われます。

context '暗黙的な定義' do
  before { create(:apple_factory_by_implicit) }

  it do
    expect(Apple.last.area.name).to eq('青森県')
  end
end

 

明示的定義

明示的なfactory定義は、 associationfactory を渡せばよいです。

以下の例ではAreaモデルのfactory nagano_area を指定しています。

# rspec/factories/apples.rb
factory :apple_factory_by_explicit do
  association :area, factory: :nagano_area
end

 
使い方は暗黙的と同じです。

context '明示的な定義' do
  before { create(:apple_factory_by_explicit) }

  it do
    expect(Apple.last.area.name).to eq('長野県')
  end
end

 

インライン定義

インライン定義の場合は、 association にfactoryを指定します。

以下の例ではAreaモデルのfactory iwate_area を指定しています。

# rspec/factories/apples.rb
factory :apple_factory_by_inline do
  area { association :iwate_area }
end

 
使い方も同様です。

context 'インライン定義' do
  before { create(:apple_factory_by_inline) }

  it do
    expect(Apple.last.area.name).to eq('岩手県')
  end
end

 

一側のfactoryで定義された属性を上書き

ここまでは一側のfactoryの定義に従い、関連データを生成していました。

ただ、一側のfactoryで定義された属性を上書きして関連データを生成したいこともあります。

その場合は、一側のfactoryを定義するときに、上書きしたい属性も合わせて定義すれば良さそうです。

 
例えば、一側のAreaモデルのfactoryが以下のように定義されていたとします。

# rspec/factories/areas.rb
factory :area do
  name { '日本' }
end

 
このとき、 area factoryで定義された属性 name を上書きしたい場合は、以下のように name を指定して上書きしたい属性の値を渡します。

# rspec/factories/apples.rb
factory :apple_factory_with_overriding_attributes do
  association :area, factory: :area, name: '山形県'
end

 
使い方は今までと同じです。

context '関連先のfactoryで定義している属性の上書き' do
  before { create(:apple_factory_with_overriding_attributes) }

  it do
    expect(Apple.last.area.name).to eq('山形県')
  end
end

 

Associationsを定義する場所について

ここまで見てきた通り、factoryの中にAssociationsを定義することで関連データを生成できます。

ただ、

# rspec/factories/apples.rb
FactoryBot.define do
  factory :apple do
    area # 関連を指定
  end
end

となっていた場合、create(:apple) とするごとにareaが生成されます。

そのため、仮にareaが不要な場合は困ってしまうかもしれません。

 
そこで、

  • factoryの継承
  • trait

のいずれかの方法により、areaが不要な場合には生成しないよう定義できます。

 

factoryの継承

まずは、factoryの継承という機能を使って、必要に応じてareaを生成するようにしてみます。

ちなみに、factoryの継承について公式ドキュメントには

  • parentを指定したfactory
  • ネストしたfactory (sub factory)

の2種類の記載がありました。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#inheritance

それぞれ見ていきます。

 

parentを指定したfactory

apple factoryと同階層に別のfactory apple_with_parent を定義し、 parent として親factoryである apple を指定します。

また、別factoryの中で関連を指定します。

# rspec/factories/apples.rb
FactoryBot.define do
  factory :apple do
    # ベースとなるfactoryでは関連を指定しない
  end

  # parentを指定したfactoryを用意し、関連を指定する
  factory :apple_with_parent, parent: :apple do
    area
  end
end

 
これにより、

  • areaが不要なときは apple factoryを使う
  • areaが必要なときは apple_with_parent factoryを使う

という方法が取れます。

context 'parent指定したfactoryを使う' do
  before { create(:apple_with_parent) }

  it do
    expect(Apple.last.area.name).to eq('日本')
  end
end

 

ネストしたfactory

factory_botでは、factoryの中にfactoryをネストして定義することができます。

ネストしている場合、一階層上のfactoryの定義を継承できます。

例えば、 apple factoryの中に apple_association_by_implicit factoryを定義し、その中で関連を指定したとします。

FactoryBot.define do
  factory :apple do
    factory :apple_association_by_implicit do
      area
    end
  end
end

 
これでも、areaが必要なときと不要なときでfactoryの使い分けができるようになります。

context '暗黙的な定義' do
  context '関連を生成する' do
    before { create(:apple_association_by_implicit) }

    it '関連が生成されていること' do
      expect(Apple.last.area.name).to eq('日本')
    end

    it 'その他の属性は継承されていること' do
      expect(Apple.last.name).to eq('秋映')
    end
  end

  context '関連を生成しない' do
    before { create(:apple) }

    it '関連が生成されていないこと' do
      expect(Apple.last.area.blank?).to eq(true)
    end

    it 'その他の属性は継承されていること' do
      expect(Apple.last.name).to eq('秋映')
    end
  end
end

 

traitを使った生成

trait1つ使う

factory_botには trait という、属性をグルーピングする機能があるため、これも試してみます。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#traits

まずfactoryに trait を定義します。

FactoryBot.define do
  factory :apple do
    trait :aomori_apple do
      association :area, factory: :aomori_area
    end
  end
end

 
create 時にtrait名のシンボルを渡すことで、 trait で指定した属性の値がfactoryの生成時に使われるようになります。

以下では apple factoryを使うときに aomori_apple trait を使用して、関連 area を生成しています。

context 'trait 1つ' do
  # trait `aomori_apple` を使う
  before { create(:apple, :aomori_apple) }

  it do
    expect(Apple.last.area.name).to eq('青森県')
  end
end

 

traitを複数使ったときに優先されるtrait

factory_botにて生成する際、 trait は複数指定できます。

例えば、以下のような同じ関連だけど別のnameを設定するtraitが複数あるとします。

FactoryBot.define do
  factory :apple do
    trait :aomori_apple do
      association :area, factory: :aomori_area
    end

    trait :hokkaido_apple do
      association :area, factory: :hokkaido_area
    end
  end
end

 
これら複数のtraitを1回の生成で利用した場合、何が優先されるのかをみていきます。

 

create時にtraitを指定

まずは create 時にtraitを指定する場合です。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#using-traits

 
今回は、 aomori_apple > hokkaido_apple の順で指定します。

create(:apple, :aomori_apple, :hokkaido_apple)

 
すると、後ろで指定したtrait (今回であれば hokkaido_apple ) の定義が利用されました。

context 'createで複数traitを指定' do
  before { create(:apple, :aomori_apple, :hokkaido_apple) }

  it do
    # 後ろのtraitが適用される
    expect(Apple.count).to eq(1)
    expect(Apple.last.area.name).to eq('北海道')
  end
end

 

factoryの定義に traits を指定

factoryでtraitを使用する場合、 traits にtrait名のシンボルを渡すことで複数のtraitを適用できます。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#defining-traits

 
factoryでtraitsを指定する場合も、先ほど同様 aomori_apple > hokkaido_apple の順で指定します。

factory :multiple_traits, traits: %i[aomori_apple hokkaido_apple]

 
すると、この場合も後ろで定義したtrait (hokkaido_apple) が利用されました。

context 'factoryで複数traitを指定' do
  before { create(:multiple_traits) }

  it do
    # 後ろのtraitが適用される
    expect(Apple.count).to eq(1)
    expect(Apple.last.area.name).to eq('北海道')
  end
end

 

一側のfactoryで、関連データを生成(callback)

ここまでは多側のfactoryを使うときに一側の関連データも生成する例を見てきました。

一方、callbackという仕組みを使えば、一側のデータ生成時に多側の関連データを作成することもできます。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#callbacks

 
今回は一側である area のfactoryにて、create後のcallback after(:create) を使って関連データを生成してみます。

まずはfactoryを定義します。

factory :callback_area do
  name { '秋田県' }
  after(:create) { |area| create(:apple, area: area) }
end

 
続いて、定義したfactoryを利用してみると、callbackで指定した内容で applearea の関連が作成されています。

context 'factoryのcallbackで生成' do
  before { create(:callback_area) }

  it do
    expect(Apple.last.area.name).to eq('秋田県')
  end
end

 

transientを利用した条件分岐による関連の生成

factory_botには transient という、factory内でのみ利用できる属性を定義する機能があります。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#transient-attributes

 
transient の使用例については、以下の記事が参考になりました。

 
今回は transient でフラグを定義し、フラグが true であれば

  • 関連を生成
  • apple.nameの先頭に シナノ を追加する

という処理を行うcallbackを定義して試してみます。

factory :apple do
  trait :with_transient do
    transient do
      is_nagano { false }
    end

    after(:create) do |apple, evaluator|
      if evaluator.is_nagano
        # areaはoptionalなbelongs_toなので、後から追加可能
        apple.area = create(:nagano_area)
        apple.name = "シナノ#{apple.name}"
        apple.save!
      end
    end
  end
end

 
この trait を使い、 transient のキーを指定する/しないでどう変わるかを見ていきます。

 

transientのキーを引数で指定しない

create時に is_nagano を指定しない場合は、callbackの内容は反映されません。

context '引数なし' do
  before { create(:apple, :with_transient, name: 'フジ',) }

  it do
    actual = Apple.last

    expect(actual.area).to eq(nil)
    expect(actual.name).to eq('フジ')
  end
end

 

transientのキーを引数で指定する

一方、create時に is_nagano: true を指定した場合は、callbackの内容が反映されます。

context '引数あり' do
  before { create(:apple, :with_transient, name: 'フジ', is_nagano: true) }

  it do
    actual = Apple.last

    expect(actual.area.name).to eq('長野県')
    expect(actual.name).to eq('シナノフジ')
  end
end

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/7