TL;DR
Override a factory's init strategy to have it start life as the STI class, avoiding the need to call .becomes
or .refind
.
FactoryBot.define do
factory :invoice do
initialize_with { type.present? ? type.constantize.new : Invoice.new }
Discussion
When I started work on the current project, there were no traits in use and work with STI models was cumbersome. Instead of traits there was a separate factory for every type. This arguably can be a reasonable setup, provided all STI factories inherit from the "abstract" base factory, and shared (or all) traits are defined there, rather than possibly repeated in each STI factory.
# how it started out
FactoryBot.define do
# the abstract, not intended to be called
factory :invoice do
trait :paid do
status { "paid" }
end
end
# STI factory, intended to be called
factory(
:additional_cost_invoice,
parent: :invoice, class: "InvoiceAdditionalCost"
) do
# not a fan of this trait that's available for this type of invoice, but could be a legit use-case.
trait :only_for_additioanl_costs do
end
end
end
# call example
FactoryBot.create(:additional_cost_invoice, :paid)
#=> InvoiceAdditionalCost
Still, I believe that it's least surprising to just have one factory and traits for each type.
FactoryBot.define do
# no longer abstract, the one and only
factory :invoice do
trait :paid do
status { "paid" }
end
trait :additional_cost do
type { "InvoiceAdditionalCost" }
end
# now have to remember not to use this elsewhere, a minor problem
trait :only_for_additioanl_costs do
end
end
end
# call example
FactoryBot.create(:invoice, :additional_cost, :paid)
#=> Invoice
FactoryBot.create(:invoice, :additional_cost, :paid).refind # or becomes
#=> InvoiceAdditionalCost
Passing in the type from trait after initialisation has several drawbacks. First is needing to manage the STI type either by reloading the record from DB or manually using becomes
. The other problem is validations and callbacks - only those defined on base Invoice
class get triggered, especially during creation.
To fix this problem, FactoryBot allows specifying a custom init block (as explained in README's "Custom Construction" section)
FactoryBot.define do
factory :invoice do
initialize_with { type.present? ? type.constantize.new : Invoice.new }
end
end
# call examples
FactoryBot.build(:invoice) #=> Invoice
FactoryBot.build(:invoice, :additional_cost)
#=> InvoiceAdditionalCost
This fixes both problems and allows having one base factory with type traits.
Top comments (4)
That was extremely helpful
Exactly what I needed, thanks !
legend!
Awesome, thanks!