DEV Community

loading...

Simple trick to make FactoryBot work with STI

epigene profile image Augusts Bautra ・2 min read

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This fixes both problems and allows having one base factory with type traits.

Discussion (0)

pic
Editor guide