paulelliott / fabrication

This project has moved to GitLab! Please check there for the latest updates.
https://gitlab.com/fabrication-gem/fabrication
MIT License
998 stars 97 forks source link

Possible to create a random_fabricator? #274

Closed swrobel closed 8 years ago

swrobel commented 8 years ago

I have several STI models where I'd like to be able to randomly fabricate one of them. Here's a simplified example:

Fabricator(:question) do
  label do
    [
      "How would you describe your policy on #{Faker::Hacker.noun.pluralize}?",
      "How many #{Faker::Hacker.noun.pluralize} do you operate?",
      "Where is the backup for #{Faker::Hacker.noun.pluralize} located?",
      "Provide details about your use of #{Faker::Hacker.abbreviation}.",
      "Describe your process for #{Faker::Hacker.ingverb}.",
      "Provide details about your use of #{Faker::Hacker.adjective} technology.",
    ].sample
  end
end

Fabricator(:text_question, from: :question) do
  type 'TextQuestion'
end

Fabricator(:select_question, from: :question) do
  type { %w(SingleSelectQuestion MultiSelectQuestion).sample }
  options %w(yes no maybe probably unknown)
end

Fabricator(:single_select_question, from: :select_question) do
  type 'SingleSelectQuestion'
end

Fabricator(:multi_select_question, from: :select_question) do
  type 'MultiSelectQuestion'
end

Fabricator(:random_question, from: [:text_question, :single_select_question, :multi_select_question].sample) do
end

The part that doesn't work is the dynamic from, which seems to be evaluated at load-time, and not call-time. I've tried wrapping it in a block and a proc, to no avail. Any helpful suggestions?

If possible, I'd like to submit a documentation PR, but I've tried everything I can think of.

paulelliott commented 8 years ago

OoooooOOooo this is a good one! 😄

The reason your :random_question fabricator is never random is because the from array sample is evaluated as an argument to the fabricator definition. In other words, when this file is initially processed by Ruby it picks one from the list and that's the one used by the whole process.

I think in this case you'll actually want to make a helper method. Instead of the :random_question fabricator, try this:

def random_question(attributes={})
  Fabricate(%i(text_question single_select_question multi_select_question).sample, attributes)
end

Then somewhere in your tests you'll just say...

let(:question) { random_question(label: 'What... is your quest?') }

If you'd like to add to or clarify the docs PRs are always welcome. They are in a separate repo though.

https://github.com/paulelliott/fabrication-site

swrobel commented 8 years ago

Haha, glad you enjoyed that. I thought you might. I actually tried that but couldn't figure out the right place to define the helper so that it would be available in all of my fabricators. Suggestions?

I'm also wondering if the following will set the association properly:

Fabricator(:template)
  questions { 10.times { random_question() } }
end
paulelliott commented 8 years ago

Ah, so when you're inside a definition block it is being evaluated in the context of a BasicObject so many of the things you're used to being present are not. This is so nothing that would normally be in an object steps on the toes of attributes in your models. I think the easiest thing in your case is to just monkey patch it into the Fabricate module.

def Fabricate.random_question
  ...
end

Fabricator(:template)
  questions { 10.times { Fabricate.random_question } }
end
swrobel commented 8 years ago

Almost there...

def Fabricate.random_question(attributes = {})
  Fabricate(%i(section_heading text_question select_question).sample, attributes)
end

Fabricator(:question) do
  template
end

Fabricator(:template) do
  questions { 10.times { Fabricate.random_question() } }
end

Now when I try to Fabricate(:template) I'm getting SystemStackError: stack level too deep probably resulting from the question fabricator trying to fabricate a template and vice versa on to infinity...

paulelliott commented 8 years ago

Well you set up an infinite loop in your fabricators. You can avoid this by overriding the template in the question in the template. Setting it to nil will allow ActiveRecord to properly associate them when it persists everything.

Fabricator(:template) do
  questions { 10.times { Fabricate.random_question(template: nil) } }
end
swrobel commented 8 years ago

Thanks for sticking with me on this. I finally got it working, but I'm hesitant to update the docs with a hack such as this.

# config/initializers/random_question_fabricator.rb

if defined? Fabricate
  class Fabricate
    class << self
      def build_random_question(attributes = {})
        Fabricate.build(%i(text_question single_select_question multi_select_question).sample, attributes)
      end

      def random_question(_attributes = {})
        build_random_question.tap(&:save)
      end
    end
  end
end

# test/fabricators/template_fabricator.rb

Fabricator(:template) do
  # Apparently 10.times.map works, 10.times does not
  # but rubocop insists 10.times.map should be written Array.new(10)
  questions { Array.new(10) { Fabricate.build_random_question } }
end

# In tests (using Test::Unit on Rails 4.2 but I imagine this will work w/ rspec)

Fabricate.random_question

# or

Fabricate.build_random_question

Anyway, my discoveries in experimentation were:

Any thoughts on modifying from: to accept a block? I could attempt a PR.

paulelliott commented 8 years ago

I don't think adding this case to the docs is worth doing. If you find anything in the docs that is confusing or out of date I would appreciate at least an issue pointing it out though.

Definitely a load order thing. You'll likely need to put it in your spec_helper.rb or equivalent.

Fabrication will choose to build automatically in certain circumstances. It makes the best decisions it can for you but at the end of the day you need to really understand how ActiveRecord works and persists object trees for more advanced use cases.

So what you're doing here is a little unusual. I don't think modifying from to accept a block is a good idea. I'd actually rather reduce the acceptable values for that parameter to just a string representation of the class name.