thoughtbot / factory_bot

A library for setting up Ruby objects as test data.
https://thoughtbot.com
MIT License
7.92k stars 2.61k forks source link

Can traits modify attributes inline? #753

Closed jfelchner closed 8 years ago

jfelchner commented 9 years ago

In the docs it says that the latest trait's attribute definition takes precedence over any prior traits and this works great for regular attributes, but for something like a configuration hash, the desire would be to merge the results of multiple traits together.

I tried doing this using @instance (I know, I know) and that worked great for one trait, but predictably based on the docs, only the last one executed rather than being stacked.

Here's a concrete example:

FactoryGirl.define do
  factory 'user' do
    trait :snoozed do
      configuration do
        @instance.configuration.deep_merge(
          'notifications' => {
            'snooze' => {
              'enabled'     => true,
              'starting_at' => Time.now,
              'ending_at'   => Time.now,
            }
          }
        ).to_h
      end
    end

    trait :all_types_enabled do
      configuration do
        @instance.configuration.deep_merge(
          'notifications' => {
            'text'  => {
              'enabled' => true,
            },
            'email' => {
              'enabled' => true,
            },
          },
        ).to_h
      end
    end
  end
end

FactoryGirl.create(:user, :snoozed, :all_types_enabled)

I would prefer that the resulting object contained a configuration hash of:

'notifications' => {
  'text'  => {
    'enabled' => true,
  },
  'email' => {
    'enabled' => true,
  },
  'snooze' => {
    'enabled'     => true,
    'starting_at' => Time.now,
    'ending_at'   => Time.now,
  }
}

So my question is, is this currently possible and if not, as the prevalence of JSON columns in the DB increases, is this something that might be coming down the line?

ArthurN commented 9 years ago

I had this same issue, and I solved it using transient attributes. It's not elegant but not bad either. I do agree it's probably going to become a more common scenario. We make liberal use of hstore attributes on Postgres.

Here is your example adapted, in case it helps someone else:

FactoryGirl.define do
  factory :user do

    transient do
      configuration_snoozed false
      configuration_all_types_enabled false
    end

    trait :snoozed do
      configuration_snoozed true
    end

    trait :all_types_enabled
      configuration_all_types_enabled true
    end

    # Build up an hstore attribute (or whatever Hash-based thing you have) 
    # named `configuration` based on value of transient attributes
    configuration do 
      hash = {}
      hash.deep_merge!(
        'notifications' => {
          'snooze' => {
            'enabled'     => true,
            'starting_at' => Time.now,
            'ending_at'   => Time.now,
          }
        }) if configuration_snoozed

      hash.deep_merge!(
        'notifications' => {
          'text'  => {
            'enabled' => true,
          },
          'email' => {
            'enabled' => true,
          },
        }) if configuration_all_types_enabled
      hash      
    end

  end
end

Then:

create(:user, :snoozed, :all_types_enabled)  # should do what you want

If you don't want the syntactic sugar of traits (or want to avoid adding an extra layer of redirection), just drop the traits and create like so:

create(:user, configuration_snoozed: true, configuration_all_types_enabled: true)  # same
joshuaclayton commented 8 years ago

@ArthurN transient attributes seems like an excellent approach here!

enernico commented 2 months ago

Hi all, very nice I could find this thread to confirm that for now, there's no feature to merge Hash attributes in _factorybot.

@ArthurN's solution is quite nice, yet it implies adding one transient attribute per trait. This is a bit redundant, so I played around and found another solution, for anyone interested (probably has downsides, too!)

EDIT: Found an even better solution, basically using after(:build) do |object| + a merge method:

FactoryBot.define do
  class Meal
    attr_accessor :data

    def merge_data(more_data)
      hash = data || {}
      self.data = hash.deep_merge(more_data)
    end
  end

  factory :meal do
    trait :ab do
      after(:build) do |object|
        object.merge_data(
          {
            a: { label: "Apple" },
            b: { label: "Banana" },
          }
        )
      end
    end

    trait :bc do
      after(:build) do |object|
        object.merge_data(
          {
            b: { label: "Baguette" },
            c: { label: "Cheese " },
          }
        )
      end
    end
  end
end
Previous, obsolete solution ```ruby FactoryBot.define do class Data def self.merge(data) @data ||= {} @data.deep_merge!(data) end def self.reset data = @data @data = nil data end end factory :meal do after(:create) do |object, _context| object.data = Data.reset end trait :ab do before(:create) do Data.merge( { a: { label: "Apple" }, b: { label: "Banana" }, } ) end end trait :bc do before(:create) do Data.merge( { b: { label: "Baguette" }, c: { label: "Cheese " }, } ) end end end end ```

Then we're able to mix traits and the Hash structures will be deep merged in the final object created:

object = create(:meal, :ab, :bc)
object.data.as_json
=> {"a"=>{"label"=>"Apple"}, "b"=>{"label"=>"Baguette"}, "c"=>{"label"=>"Cheese "}}

object = create(:meal, :bc, :ab)
object.data.as_json
=> {"b"=>{"label"=>"Banana"}, "c"=>{"label"=>"Cheese "}, "a"=>{"label"=>"Apple"}}

Notice how the latest trait's nested keys override the same keys that were present in previous traits.

As a conclusion, I'd love to have that natively available in _factorybot!