thoughtbot / factory_bot

A library for setting up Ruby objects as test data.

Home Page:https://thoughtbot.com

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Orphaned Record Creation via Association/Trait

AlessandroMinali opened this issue · comments

Description

When two factories call each other via association it will result in stack blow up as it recurses infinitely. If one of the association calls is placed within a trait then FB proceeds forward creating the associated record as desired but then creates a second record which is the one finally linked via the foreign_key. This ends up creating extra records in the DB that are "unused".

Reproduction Steps

https://gist.github.com/AlessandroMinali/86ceff2385726ef99233cdba91b06125

Expected behavior

If I call association anywhere in a factory and the associated factory requires the caller, there is only one caller record created and it is referenced via the foreign_key in the callee.

Actual behavior

If I call association in a trait and the associated factory requires the caller, two caller records are created and the second record is referenced via the foreign_key in the callee.

System configuration

factory_bot version: 6.2.0
rails version: n/a
ruby version: 2.7.3

What you've described is basically the expected behavior with interconnected associations like that. One option is to tell factory_bot how to connect the associations to each other using instance as described in https://github.com/thoughtbot/factory_bot/blob/master/GETTING_STARTED.md#interconnected-associations.

Why would creating extra records be "expected behaviour"? Either it does the right thing or explodes (as if I did't wrap it in a trait block).

Stripping away a few layers of factory_bot, putting associations on both models is roughly equivalent to this:

def create_user
  user = User.new
  user.account = create_account
  user.save!
end

def create_account
  account = Account.new
  account.account = create_user
  account.save!
end

create_account # starts infinite loop

The expected behavior of that code is to loop forever. Looping forever is not desirable, so we need to modify that code.

Switching to a trait is essentially like this:

def create_user
  user = User.new
  user.account = create_account
  user.save!
end

def create_account(with_user: false)
  account = Account.new
  account.account = create_user if with_user
  account.save!
end

create_account(with_user: true)

This does break the infinite loop, but not quite soon enough. There's nothing here to tell create_user that another account exists that it should use instead. To get that working you'd need to pass in the account instance when creating the user:

def create_user(account: nil)
  user = User.new
  user.account = account || create_account
  user.save!
end

def create_account
  account = Account.new
  account.user = create_user(account: account)
  account.save!
end

That's what instance is for in the documentation I linked to. It's not something factory_bot can otherwise infer.


Perhaps also worth a mention, I don't usually add factory_bot association declarations to both sides of an association pair (unless I really have to because there are validations on both sides). I usually only put associations on the belongs_to side since they are often required for the record to be valid. So I would probably have started with:

FactoryBot.define do
  factory :user do
    association :account
  end

  factory :account
end

When I need account with a user in a test, I would then write:

user = create(:user)
account = user.account

I know some people don't like that, but my take is that factory_bot is really good at building simple objects with default attributes, and not really designed for building up interconnected networks of associations.

Thanks for the great explanation!

This is clear to me now. I will close the issue.