How to make FactoryBot return the right STI sub class?
Asked Answered
R

5

7

I'm making a big change in my system, so I changed one of my main tables into a STI, and create subclasses to implement the specific behavior.

class MainProcess < ApplicationRecord
end

class ProcessA < MainProcess
end

class ProcessB < MainProcess
end

In the application code, if I run MainProcess.new(type: 'ProcessA') it will return a ProcessA as I want. But in the Rspec tests when I run FactoryBot::create(:main_process, type: 'ProcessA') it is returning a MainProcess and breaking my tests.

My factor is something like this

FactoryBot.define do
  factory :main_process do
    foo { 'bar' }
  end

  factory :process_a, parent: :main_process, class: 'ProcessA' do
  end

  factory :process_b, parent: :main_process, class: 'ProcessB' do
  end
end

Is there some way to make FactoryBot have the same behavior of normal program?

Ruminant answered 15/8, 2019 at 3:8 Comment(0)
R
6

I found the solution

FactoryBot.define do
  factory :main_process do
    initialize_with do
      klass = type.constantize
      klass.new(attributes)  
    end
  end
  ...
end

The answer was founded here http://indigolain.hatenablog.com/entry/defining-factory-for-sti-defined-model (in japanese)

Edit #1:

⚠⚠⚠ Important ⚠⚠⚠

As mentioned here initialize_with is part of a private FactoryBot API.

According to the documentation:

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

So avoid to use if you can. (although I didn't find any other way to achieve this result without use it)

Edit #2

Besides the warning in the gem documentation (described above), the GETTING_STARTED.md actually suggest you use it

If you want to use factory_bot to construct an object where some attributes are passed to initialize or if you want to do something other than simply calling new on your build class, you can override the default behavior by defining initialize_with on your factory

Ruminant answered 15/8, 2019 at 6:59 Comment(2)
Brilliant! I've been looking for a way to have my subclass instances be wrapped in traits, and this was the solution to do it.Harper
Yes, this worked for me in factory_bot 6.1, rspec-core 3.10, and rspec-rails 5.0.1. While @Jared's solution below really should work per the FactoryBot documentation, apparently it doesn't currently with STI. :(Hexagon
A
4

initialize_with is marked as part of FactoryBot's Private API and not recommended for external use.

I think you can use nested factories to accomplish this.

  factory :process do

    factory :type_a_process, class: Process::TypeA do
      type {"Process::TypeA"}
    end

    factory :type_b_process, class: Process::TypeB do
      type {"Process::TypeB"}
    end

  end
end

FactoryBot.create(:type_b_process)
Azeria answered 27/5, 2022 at 18:56 Comment(1)
Thanks, I added a note about your comment in the answer.Ruminant
G
2

If you just modify your original code to specify the class as the class type instead of a string, it works:

FactoryBot.define do
  factory :main_process do
    foo { 'bar' }
  end

  factory :process_a, parent: :main_process, class: ProcessA do
  end

  factory :process_b, parent: :main_process, class: ProcessB do
  end
end

Here's the relevant section of the FactoryBot documentation.

Garrote answered 23/8, 2019 at 0:40 Comment(1)
This seemed to work at first for me. However, when I tried a = build(:process_a); a.class I got what was expected: class ProcessA < MainProcess, but a.instance_of?(ProcessA) I got false, and could no longer run a.reload. For some reason, @Alan Alves de Oliveira's answer worked. Running factory_bot 6.1, rspec-core 3.10, and rspec-rails 5.0.1.Hexagon
Q
1

This is better:

initialize_with { type.present? ? type.constantize.new : Invoice.new }

https://dev.to/epigene/simple-trick-to-make-factorybot-work-with-sti-j09

Quench answered 9/9, 2022 at 19:25 Comment(0)
J
0

If you want to use use traits, this is how you could do it:

FactoryBot.define do
  factory :main_process do
    foo { 'bar' }
    type { 'DefaultProcess' }

    initialize_with { type.constantize.new }

    trait :process_a do
      type { 'ProcessA' }
    end

    trait :process_b do
      type { 'ProcessB' }
    end
  end
end
Jorge answered 18/6 at 23:43 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.