在Rails中使用factory_girl处理具有唯一约束的关联对象时,会出现重复错误问题。

42
我正在处理一个Rails 2.2项目,需要进行更新。我正在使用factory_girl替换现有的fixtures,并遇到了一些问题。问题出在代表查找数据表的模型上。当我创建一个Cart并添加两个具有相同产品类型的产品时,每个创建的产品都会重新创建相同的产品类型。这导致ProductType模型上的唯一性验证出错。

问题演示

这是一个单元测试的结果,我将Cart分别组合起来。我必须这样做才能解决问题。但这仍然说明了问题。我将解释。

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))]

需要添加的两个产品是同一类型的,每次创建产品时都会重新创建产品类型并创建副本。

生成的错误如下: “ActiveRecord :: RecordInvalid:验证失败:名称已被使用,代码已被使用”

解决方法

此示例的解决方法是覆盖正在使用的产品类型并传递特定实例,以便只使用一个实例。提前获取“add_product_type”,并将其传递给每个购物车项。

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

问题

如何最好地使用factory_girl处理“pick-list”类型的关联?

我希望工厂定义包含所有内容,而不是在测试中进行组装,但如果必要,我可以接受。

背景和额外细节

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

ProductType的“code”的目的是使应用程序能够赋予它们特殊含义。ProductType模型大致如下:

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

当我试图使用两个相同产品类型的项目定义购物车时,我得到了上述描述的相同错误。
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
10个回答

48

顺便提一下,你也可以在你的工厂里使用initialize_with宏来检查对象是否已经存在,然后不再重复创建。使用lambda表达式的解决方案(很棒,但是!)复制了已经在find_or_create_by中存在的逻辑。这对于通过关联工厂创建:league的情况也适用。

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
当然,这需要依赖ActiveRecord,但对于大多数人来说这不应该是个问题。简单、优雅、灵活...非常好。 - Tim Yates
1
此外,这也适用于嵌套工厂和其他属性。非常棒的解决方案! - Jeff Tratner
3
不幸的是,Rails 4 中的 find_or_create_by_id 已经被弃用了。而且由于某些原因,find_or_create_by 的功能也出现了问题。 - Pavel K.
谢谢你提醒我,Pavel。这个答案相当老了,可能需要重新审视! - CubaLibre
2
@PavelK。不要使用League.where(id:1,name:“欧洲锦标赛”,rank:30).first_or_create初始化。 - Dofs

31

我遇到了同样的问题,并在我的工厂文件顶部添加了一个实现单例模式的 lambda 函数,如果数据库自上一轮测试/规范以来已被清除,该函数还会重新生成模型:

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

然后,使用你的示例工厂,你可以使用factory_girl中的惰性属性来运行lambda表达式

Factory.define :product do |p|
  p.product_type  { single_instances[:add_product_type] }
  #...this block edited as per comment below
end

看这里!


非常棒的解决方案!感谢分享。它让使用变得简单而优雅。正是我所需要的。 :) - Mark Eric
1
谢谢!这正是我在寻找的。请注意,在lambda中可以省略“return”行,因为开始或救援块将已经返回正确的值。 - lawrence
2
第二个代码块中有一个小错别字。它应该说“single_instances”而不是“single_instance”。不过仍然是很棒的解决方案。 - dafmetal
有没有办法在工厂块之外使用 single_instance lambda?我已经考虑了 Factory(:product).product_type 的解决方法,但是希望能够更直接地获取 product_type。 - Kostas
1
非常好的解决方案。看起来应该内置到Factory Girl中。 - KobeJohn
这个很好用。同意,不需要 return saved_single_instances[factory_key] - netwire

3

编辑:
请查看本回答底部更加简洁的解决方案。

原始回答:
这是我的解决方案,用于创建FactoryGirl的单例关联:

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

你可以这样称呼它,比如:

And the following platform versions exists:
  | Name     |
  | Master   |
  | Slave    |
  | Replica  |

通过这种方式,所有3个平台版本都将拥有相同的平台“Foo”,即单例。

如果你想要节省一个数据库查询,可以使用以下方法:

platform {
  search = Platform.find(:first)
  if search.blank?
    FactoryGirl.create(:platform)
  else
    search
  end
}

您可以考虑将单例关联定义为一个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

如果您想设置多个平台,并且拥有不同的平台版本集合,您可以创建更具体的不同特征,例如:

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

希望这对一些人有用。

编辑:
提交了上面的原始解决方案后,我再次查看了代码,并找到了更简洁的方法:您不需要在工厂中定义traits,而是在调用测试步骤时指定关联。

创建常规工厂:

FactoryGirl.define do
  factory :platform do
    name 'Foo'
  end

  factory :platform_version do
    name 'Bar'
    platform
  end
end

现在您可以按照指定的关联来调用测试步骤:
And the following platform versions exists:
  | Name     | Platform     |
  | Master   | Name: NewFoo |
  | Slave    | Name: NewFoo |
  | Replica  | Name: NewFoo |

当按照这种方式进行时,创建平台“NewFoo”使用“find_or_create_by”功能,因此第一次调用会创建平台,接下来的两次调用会查找已经创建的平台。
通过这种方式,所有三个平台版本都将拥有相同的平台“NewFoo”,您可以根据需要创建尽可能多的平台版本集。
我认为这是一个非常干净的解决方案,因为您保持工厂的清洁,并且甚至向测试步骤的读者显示这3个平台版本都具有相同的平台。

你底部的更简洁的解决方案看起来不错,但无法控制NewFoo的其他属性。例如,如果关联模型是一个具有“firstname”和“lastname”字段的人怎么办? - Adam Spiers
是的,它只允许控制一个属性。然而,它已经解决了我大部分的用例。 也许可以重写测试步骤以控制n个属性。 - Jonas Bang Christensen

2
我曾经遇到过类似的情况。我最终使用seeds.rb来定义单例,并在spec_helper.rb中引用seeds.rb将对象创建到测试数据库中。然后,我可以在工厂中查找适当的对象。

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

2

简短的回答是,“不”,Factory Girl没有更干净的方法来做到这一点。我似乎在Factory Girl论坛上验证了这一点。

然而,我为自己找到了另一个答案。它涉及另一种解决方法,但使一切更加清洁。

想法是改变代表查找表的模型,以创建所需的条目(如果缺少)。这是可以的,因为代码期望特定的条目存在。这是修改后的模型的示例。

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

"id_for_addition"这个静态类方法会加载模型和ID,如果找到了就会加载,如果没有找到就会创建。

缺点在于“id_for_addition”这个方法的名称可能不够清晰,需要改进。对于正常使用,唯一的其他代码影响是额外测试是否找到了模型。

这意味着创建产品的工厂代码可以像这样更改...

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

这意味着修改后的工厂代码可以如下所示...
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

这正是我想要的。 我现在可以清晰地表达我的工厂和测试代码。

这种方法的另一个好处是查找表数据不需要在迁移中进行播种或填充。 它将为测试数据库以及生产环境自行处理。


尝试将此内容发布到factory_girl的github问题中https://github.com/thoughtbot/factory_girl/issues - Scott Schulthess
在提出建议之前,请尝试在 https://github.com/thoughtbot/factory_girl/issues 上搜索“singleton”:( - Adam Spiers

2

太好了!谢谢你告诉我这个。我会关注的。 - Mark Eric
请参见 https://github.com/thoughtbot/factory_girl/issues/24 和 https://github.com/thoughtbot/factory_girl/issues/148。factory_girl团队显然对此视而不见。 - Adam Spiers

1

受到这里的答案的启发,我发现@Jonas Bang的建议最符合我的需求。以下是在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

使用它创建具有相同平台引用的四个平台版本的示例:

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

如果您需要在不同的平台上使用'Dar':

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

感觉就像是两全其美,不会过度弯曲factory_girl的形状。

1

0

我认为我至少找到了一种更简洁的方法。

我喜欢联系ThoughtBot以获取推荐的“官方”解决方案的想法。目前,这个方法很有效。

我只是将在测试代码中完成的方法与在工厂定义中完成的方法结合起来。

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

如果我找到更好的解决方案,我会进行更新。


0
也许你可以尝试使用factory_girl的sequences来为产品类型名称和代码字段生成数据?对于大多数测试来说,你可能不太关心产品类型代码是“code 1”还是“sub”,而对于那些你关心的测试,你总是可以明确指定。
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 

谢谢您的建议。在某些情况下,这可能是可以接受的。然而,在我的情况下,只有一个非常关键。代码的目的是被应用程序使用,以便它可以将特殊含义归属于Rails代码中的ProductType。 - Mark Eric

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接