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.