Hugo Barthelemy
Hugo Barthelemy

Reputation: 129

Random trait with FactoryBot

I would like to use FactoryBot to return trait randomly like that:

FactoryBot.define do
  factory :user do

    [:active, inactive].sample

    trait :active do
      active { true }
      ...
    end
    trait :inactive do
      active { false }
      ...
    end
  end
end

To do that:

(1..5).map{ |e| FactoryBot.build(:user) }.map(&:active?)
=> [true, false, false, true, false]

Actually is like that:

FactoryBot.define do
  factory :user do
    active                    { [true, false].Sample }
    name                      { "name-#{SecureRandom.uuid}" }
    birthday                  { active == true ? rand(18..99).years.ago - rand(0..365).days.ago : nil }
    preferred_contact_method  { active == true ? %w(phone email).sample : nil }
    activated_at              { active == true ? rand(1..200).days.ago : nil }
    contact_phone_number      { preferred_contact_method == "phone" ? "+33XXXXXXXXX" : nil }
    contact_email             { preferred_contact_method == "email" ? "[email protected]" : nil }
  end
end

Is it possible to do that?

Upvotes: 3

Views: 1492

Answers (3)

SMAG
SMAG

Reputation: 798

I agree with the comments on the question that in some cases, randomizing the traits at the test definition would be sufficient and appropriate. However this is not a one size fits all solution.

Would it be sufficient to write: FactoryBot.build(:user, [:active, :inactive].sample)? – Tom Lord Mar 21, 2019 at 9:25

It works but you have to think about it every time you use the factory. – Hugo Barthelemy Mar 21, 2019 at 9:42

In my case, I am wanting to randomize the factory because I need the results to be consistent regardless of the inconsistencies that come from 3rd party data. I prefer the chaos in the factory to simulate this expected randomness in the test input. Think of it as a much lighter weight adoption of the Netflix Simian Army approach sometimes referred to as adversarial debugging.

I ran into issues with both of the provided answers and found the following to work for my needs, which include maintaining the Factory's ability to accept traits in the test and still behave as expected.

FactoryBot.define do
  factory :user do
    # I am intentionally not defining a default 'active' attribute for this example, to encourage the use of the traits that have been defined.

    trait :active do
      active { true }
    end

    trait :inactive do
      active { false }
    end
  end

  factory :randomized_user, parent: :user do
    after(:build) do |user, _evaluator|
      [].then do |traits|
        traits << [:active, :inactive].sample
        # more dynamic traits, if desired

        # these keys will be strings, so we will symbolize them for compatibility with the merge method used later.
        user.attributes.symbolize_keys.then do |user_attributes|
          # since we can not render just the trait's attributes, we are going to render all of them for the parent factory with the selected traits passed in.
          # NOTE: attributes_for will generate symbolized_keys.
          random_attributes = attributes_for(:user, *traits)

          # set the records attributes to the merge of present random_attributes with the present user_attributes taking precedence
          user.attributes = random_attributes.compact.merge(user_attributes.compact)
        end
      end
    end
  end
end

Example Usage

# static factory results
FactoryBot.create_list(:user, 5).pluck(:active)            => [nil, nil, nil, nil, nil]
FactoryBot.create_list(:user, 5, :active).pluck(:active)   => [true, true, true, true, true]
FactoryBot.create_list(:user, 5, :inactive).pluck(:active) => [false, false, false, false, false]

# dynamic factory results
FactoryBot.create_list(:randomized_user, 5).pluck(:active) => [true, true, false, true, false]
FactoryBot.create_list(:randomized_user, 5).pluck(:active) => [false, true, false, false, true]

# dynamic factory results, overridden by the passed in trait taking precedence  in this solution
FactoryBot.create_list(:randomized_user, 5, :active).pluck(:active)   => [true, true, true, true, true]
FactoryBot.create_list(:randomized_user, 5, :inactive).pluck(:active) => [false, false, false, false, false]

Reasoning for this approach

  • after(:build) fires regardless of build_strategy (create or build). This block also applies all of the standard Factory logic before, instead of after, any randomization is applied. Therefor, updating the resulting attributes is all that needs to happen.
  • breaking the randomized_user into its own factory exposes the randomization to the implementation in the test and prevents it from being obfuscated within the factory definition. This also preserves the parent factory to still provide static and consistent instances.

Remember that trait order is important when implementing them

My issue with lambda approach

while this randomizes the factory it only randomizes it at the factory's definition, which means that it becomes a static definition once the factory is initialized and all factory products will be consistent until the next test run. I want randomization from one factory product to the next.

My issue with the initialize_with approach

My implementation of this answer resulted in a stack level too deep error or when tweaking the solution, I found that the factory definitions would stomp over the attributes that were being assigned in the initialize_with block, or replicate the issue from the lambda approach. Additionally, the check on an attribute being nil may work often, but can cause problems as this would become an implied rule and worse if the value can / should be nil. I prefer to be explicit.

Upvotes: 0

BroiSatse
BroiSatse

Reputation: 44725

That is quite an old question, but just had the same need as OP and I think I found the solution, alas it is not pretty:

FactoryBot.define do
  factory :user do
    active { nil }

    initialize_with do
      if active.nil?
        build :user, %i[active inactive].sample, attributes
      else
        User.new(attributes)
      end
    end
    
    trait :active do
      active { true }
      ...
    end
    trait :inactive do
      active { false }
      ...
    end
  end
end

FactoryBot.create_list(:user, 5).map(&:active) #=> random array of booleans
FactoryBot.create_list(:user, 5, :active).all?(&:active?) #=> true

Upvotes: 1

Ulysse BN
Ulysse BN

Reputation: 11423

Working on an eventual answer, I came across the fact that a trait execute only once the block it is given. I'll share the sample of code I was on for the time being, although if you test it, you will only have active users or inactive users, but not both.


You could export the logic of your traits in two lambdas and then choose one by random:

trait_active = lambda do |context|
    context.active { true }
    #...
end
trait_inactive = lambda do |context|
    context.active { false }
    # ...
end

FactoryBot.define do
    factory :user do
        trait :active do
            trait_active.call(self)
        end
        trait :inactive do
            trait_inactive.call(self)
        end
        trait :schrodinger do
            [trait_active, trait_inactive].sample.call(self)
        end
    end
end

The context attribute in the lambda is quite important here, you can see more about that in this answer.

Upvotes: 0

Related Questions