可以将文章内容翻译成中文,广告屏蔽插件可能会导致该功能失效(如失效,请关闭广告屏蔽插件后再试):
问题:
I'm working with a Rails 2.2 project working to update it. I'm replacing existing fixtures with factories (using factory_girl) and have had some issues. The problem is with models that represent tables with lookup data. When I create a Cart with two products that have the same product type, each created product is re-creating the same product type. This errors from a unique validation on the ProductType model.
Problem Demonstration
This is from a unit test where I create a Cart and put it together in pieces. I had to do this to get around the problem. This still demonstrates the problem though. I'll explain.
cart = Factory(:cart)
cart.cart_items = [Factory(:cart_item,
:cart => cart,
:product => Factory(:added_users_product)),
Factory(:cart_item,
:cart => cart,
:product => Factory(:added_profiles_product))]
The two products being added are of the same type and when each product is created it is re-creating the product type and creating duplicates.
The error that gets generated is:
"ActiveRecord::RecordInvalid: Validation failed: Name has already been taken, Code has already been taken"
Workaround
The workaround for this example is to override the product type being used and pass in a specific instance so only one instance is used. The "add_product_type" is fetched early and passed in for each cart item.
cart = Factory(:cart)
prod_type = Factory(:add_product_type) #New
cart.cart_items = [Factory(:cart_item,
:cart => cart,
:product => Factory(:added_users_product,
:product_type => prod_type)), #New
Factory(:cart_item,
:cart => cart,
:product => Factory(:added_profiles_product,
:product_type => prod_type))] #New
Question
What is the best way to use factory_girl with "pick-list" types of associations?
I'd like for the factory definition to contain everything instead of having to assemble it in the test, although I can live with it.
Background and Extra Details
factories/product.rb
# Declare ProductTypes
Factory.define :product_type do |t|
t.name "None"
t.code "none"
end
Factory.define :sub_product_type, :parent => :product_type do |t|
t.name "Subscription"
t.code "sub"
end
Factory.define :add_product_type, :parent => :product_type do |t|
t.name "Additions"
t.code "add"
end
# Declare Products
Factory.define :product do |p|
p.association :product_type, :factory => :add_product_type
#...
end
Factory.define :added_profiles_product, :parent => :product do |p|
p.association :product_type, :factory => :add_product_type
#...
end
Factory.define :added_users_product, :parent => :product do |p|
p.association :product_type, :factory => :add_product_type
#...
end
The purpose of ProductType's "code" is so the application can give special meaning to them. The ProductType model looks something like this:
class ProductType < ActiveRecord::Base
has_many :products
validates_presence_of :name, :code
validates_uniqueness_of :name, :code
#...
end
factories/cart.rb
# Define Cart Items
Factory.define :cart_item do |i|
i.association :cart
i.association :product, :factory => :test_product
i.quantity 1
end
Factory.define :cart_item_sub, :parent => :cart_item do |i|
i.association :product, :factory => :year_sub_product
end
Factory.define :cart_item_add_profiles, :parent => :cart_item do |i|
i.association :product, :factory => :add_profiles_product
end
# Define Carts
# Define a basic cart class. No cart_items as it creates dups with lookup types.
Factory.define :cart do |c|
c.association :account, :factory => :trial_account
end
Factory.define :cart_with_two_different_items, :parent => :cart do |o|
o.after_build do |cart|
cart.cart_items = [Factory(:cart_item,
:cart => cart,
:product => Factory(:year_sub_product)),
Factory(:cart_item,
:cart => cart,
:product => Factory(:added_profiles_product))]
end
end
When I try to define the cart with two items of the same product type, I get the same error described above.
Factory.define :cart_with_two_add_items, :parent => :cart do |o|
o.after_build do |cart|
cart.cart_items = [Factory(:cart_item,
:cart => cart,
:product => Factory(:added_users_product)),
Factory(:cart_item,
:cart => cart,
:product => Factory(:added_profiles_product))]
end
end
回答1:
I encountered the same problem and added a lambda at the top of my factories file that implements a singleton pattern, which also regenerates the model if the db has been cleared since the last round of tests/specs:
saved_single_instances = {}
#Find or create the model instance
single_instances = lambda do |factory_key|
begin
saved_single_instances[factory_key].reload
rescue NoMethodError, ActiveRecord::RecordNotFound
#was never created (is nil) or was cleared from db
saved_single_instances[factory_key] = Factory.create(factory_key) #recreate
end
return saved_single_instances[factory_key]
end
Then, using your example factories, you can use a factory_girl lazy attribute to run the lambda
Factory.define :product do |p|
p.product_type { single_instances[:add_product_type] }
#...this block edited as per comment below
end
Voila!
回答2:
Just FYI, you can also use the initialize_with
macro inside your factory and check to see if the object already exists, then don't create it over again. The solution using a lambda (its awesome, but!) is replicating logic already present in find_or_create_by. This also works for associations where the :league is being created through an associated factory.
FactoryGirl.define do
factory :league, :aliases => [:euro_cup] do
id 1
name "European Championship"
rank 30
initialize_with { League.find_or_create_by_id(id)}
end
end
回答3:
The short answer is, "no", Factory girl doesn't have a cleaner way to do it. I seemed to verify this on the Factory girl forums.
However, I found another answer for myself. It involves another sort of workaround but makes everything much cleaner.
The idea is to change the models that represent the lookup tables to create the required entry if missing. This is OK because the code is expecting specific entries to exist. Here is an example of the modified model.
class ProductType < ActiveRecord::Base
has_many :products
validates_presence_of :name, :code
validates_uniqueness_of :name, :code
# Constants defined for the class.
CODE_FOR_SUBSCRIPTION = "sub"
CODE_FOR_ADDITION = "add"
# Get the ID for of the entry that represents a trial account status.
def self.id_for_subscription
type = ProductType.find(:first, :conditions => ["code = ?", CODE_FOR_SUBSCRIPTION])
# if the type wasn't found, create it.
if type.nil?
type = ProductType.create!(:name => 'Subscription', :code => CODE_FOR_SUBSCRIPTION)
end
# Return the loaded or created ID
type.id
end
# Get the ID for of the entry that represents a trial account status.
def self.id_for_addition
type = ProductType.find(:first, :conditions => ["code = ?", CODE_FOR_ADDITION])
# if the type wasn't found, create it.
if type.nil?
type = ProductType.create!(:name => 'Additions', :code => CODE_FOR_ADDITION)
end
# Return the loaded or created ID
type.id
end
end
The static class method of "id_for_addition" will load the model and ID if found, if not found it will create it.
The downside is the "id_for_addition" method may not be clear as to what it does by its name. That may need to change. The only other code impact for normal usage is an additional test to see if the model was found or not.
This means the Factory code for creating the product can be changed like this...
Factory.define :added_users_product, :parent => :product do |p|
#p.association :product_type, :factory => :add_product_type
p.product_type_id { ProductType.id_for_addition }
end
This means the modified Factory code can look like this...
Factory.define :cart_with_two_add_items, :parent => :cart do |o|
o.after_build do |cart|
cart.cart_items = [Factory(:cart_item_add_users, :cart => cart),
Factory(:cart_item_add_profiles, :cart => cart)]
end
end
This is exactly what I wanted. I can now cleanly express my factory and test code.
Another benefit of this approach is the lookup table data doesn't need to be seeded or populated in migrations. It will handle itself for test databases as well as production.
回答4:
These problems would be eliminated when the singletons are introduced into factories- its currently at -http://github.com/roderickvd/factory_girl/tree/singletons
Issue - http://github.com/thoughtbot/factory_girl/issues#issue/16
回答5:
EDIT:
See an even cleaner solution at the bottom of this answer.
ORIGINAL ANSWER:
This is my solution to creating FactoryGirl singleton associations:
FactoryGirl.define do
factory :platform do
name 'Foo'
end
factory :platform_version do
name 'Bar'
platform {
if Platform.find(:first).blank?
FactoryGirl.create(:platform)
else
Platform.find(:first)
end
}
end
end
You call it e.g. like:
And the following platform versions exists:
| Name |
| Master |
| Slave |
| Replica |
In this way all 3 platform versions will have same platform 'Foo', i.e. singleton.
If you wanna save a db query you can instead do:
platform {
search = Platform.find(:first)
if search.blank?
FactoryGirl.create(:platform)
else
search
end
}
And you can consider to make the singleton association a trait:
factory :platform_version do
name 'Bar'
platform
trait :singleton do
platform {
search = Platform.find(:first)
if search.blank?
FactoryGirl.create(:platform)
else
search
end
}
end
factory :singleton_platform_version, :traits => [:singleton]
end
If you want to setup more than 1 platform, and have different sets of platform_versions, you can make different traits which are more specific, i.e.:
factory :platform_version do
name 'Bar'
platform
trait :singleton do
platform {
search = Platform.find(:first)
if search.blank?
FactoryGirl.create(:platform)
else
search
end
}
end
trait :newfoo do
platform {
search = Platform.find_by_name('NewFoo')
if search.blank?
FactoryGirl.create(:platform, :name => 'NewFoo')
else
search
end
}
end
factory :singleton_platform_version, :traits => [:singleton]
factory :newfoo_platform_version, :traits => [:newfoo]
end
Hope this is useful to some out there.
EDIT:
After submitting my original solution above, I gave the code another look, and found an even cleaner way to do this: You do not define traits in the factories, instead you specify the association when you call the test step.
Make regular factories:
FactoryGirl.define do
factory :platform do
name 'Foo'
end
factory :platform_version do
name 'Bar'
platform
end
end
Now you call the test step with the association specified:
And the following platform versions exists:
| Name | Platform |
| Master | Name: NewFoo |
| Slave | Name: NewFoo |
| Replica | Name: NewFoo |
When doing it like this, the creation of platform 'NewFoo' is using 'find_or_create_by' functionality, so the first call creates the platform, the next 2 calls finds the already created platform.
In this way all 3 platform versions will have same platform 'NewFoo', and you can create as many sets of platform versions as you need.
I think this is a very clean solution, since you keep the factory clean, and you even make it visible to the reader of your test steps that those 3 platform versions all have the same platform.
回答6:
I had a similar situation. I ended up using my seeds.rb for defining the singletons and then requiring the seeds.rb in the spec_helper.rb to create the objects into the test database. Then I can just search the appropriate object in the factories.
db/seeds.rb
RegionType.find_or_create_by_region_type('community')
RegionType.find_or_create_by_region_type('province')
spec/spec_helper.rb
require "#{Rails.root}/db/seeds.rb"
spec/factory.rb
FactoryGirl.define do
factory :region_community, class: Region do
sequence(:name) { |n| "Community#{n}" }
region_type { RegionType.find_by_region_type("community") }
end
end
回答7:
I've had this same problem, and I think it's the same one referenced here: http://groups.google.com/group/factory_girl/browse_frm/thread/68947290d1819952/ef22581f4cd05aa9?tvc=1&q=associations+validates_uniqueness_of#ef22581f4cd05aa9
I think your workaround is possibly the best solution to the problem.
回答8:
Maybe you could try using factory_girl's sequences for product type name and code fields? For most tests I guess you won't care whether the product type's code is "code 1" or "sub", and for those where you care, you can always specify that explicitly.
Factory.sequence(:product_type_name) { |n| "ProductType#{n}" }
Factory.sequence(:product_type_code) { |n| "prod_#{n}" }
Factory.define :product_type do |t|
t.name { Factory.next(:product_type_name) }
t.code { Factory.next(:product_type_code) }
end
回答9:
I think I at least found a cleaner way.
I like the idea of contacting ThoughtBot about getting a recommended "official" solution. For now, this works well.
I just combined the approach of doing it in test's code with doing it all in the factory definition.
Factory.define :cart_with_two_add_items, :parent => :cart do |o|
o.after_build do |cart|
prod_type = Factory(:add_product_type) # Define locally here and reuse below
cart.cart_items = [Factory(:cart_item,
:cart => cart,
:product => Factory(:added_users_product,
:product_type => prod_type)),
Factory(:cart_item,
:cart => cart,
:product => Factory(:added_profiles_product,
:product_type => prod_type))]
end
end
def test_cart_with_same_item_types
cart = Factory(:cart_with_two_add_items)
# ... Do asserts
end
I will update if I find a better solution.
回答10:
Inspired by the answers here I found the suggestion from @Jonas Bang the closest to my needs. Here's what worked for me in mid-2016 (FactoryGirl v4.7.0, Rails 5rc1):
FactoryGirl.define do
factory :platform do
name 'Foo'
end
factory :platform_version do
name 'Bar'
platform { Platform.first || create(:platform) }
end
end
Example of using it to create four platform_version's with the same platform reference:
FactoryGirl.create :platform_version
FactoryGirl.create :platform_version, name: 'Car'
FactoryGirl.create :platform_version, name: 'Dar'
=>
-------------------
platform_versions
-------------------
name | platform
------+------------
Bar | Foo
Car | Foo
Dar | Foo
And if you needed 'Dar' on a distinct platform:
FactoryGirl.create :platform_version
FactoryGirl.create :platform_version, name: 'Car'
FactoryGirl.create :platform_version, name: 'Dar', platform: create(:platform, name: 'Goo')
=>
-------------------
platform_versions
-------------------
name | platform
------+------------
Bar | Foo
Car | Foo
Dar | Goo
Feels like the best of both worlds without bending factory_girl too far out of shape.