Rails:如何避免Factory Girl中的重复错误?我做错了吗?

39
假设我有一个名为user的模型,它在email字段上具有唯一性限制。
如果我第一次调用Factory(:user)时一切正常,但如果我第二次调用它,它将会失败并显示"entry already exists"错误。
我目前使用一个简单的助手函数在创建工厂之前搜索数据库中是否存在重复数据,并通过该助手函数调用任何我创建的工厂。
虽然这种方法可行,但并不十分优雅。考虑到我认为这个问题很常见,我猜想可能有更好的解决方案。那么,在factory girl中是否有内置的方法可以return_or_create 工厂,而不是只是使用create()呢?如果没有,大多数人如何避免他们的工厂出现重复项?
答案:
假定我有一个模型用户(user),在电子邮件(email)字段上具有唯一性约束条件。如果我第一次调用 Factory(:user) 时,所有都很好,但如果我第二次调用它,它会失败并显示“条目已经存在”的错误。我目前使用一个简单的帮助程序在创建工厂之前搜索数据库中是否存在现有条目…并通过该帮助程序调用我创建的任何工厂。它可行,但并不完美。考虑到此问题的普遍性,我猜测有更好的解决方案。那么,factory girl是否有内置的方法来返回或创建工厂,而不仅仅是继续使用create()呢?如果没有,大多数人是如何避免他们的工厂创建重复条目的?

我也遇到了这个问题。你是否在电子邮件字段中添加了一个序列,以便理论上每次调用Factory(:user)时都会更改它?我已经这样做了,但仍然遇到了你所遇到的问题。 - someoneinomaha
2
我曾经遇到过同样的问题。我注意到 FactoryGirl 在我的测试数据库中留下了一些不良数据,这些数据来自之前失败的测试,可能抛出了异常(可能避免了清理)。我通过执行以下操作解决了这个问题: RAILS_ENV=test bin/rake db:drop RAILS_ENV=test bin/rake db:create RAILS_ENV=test bin/rake db:migrate 这样可以清除所有旧数据。希望这能帮到 @someoneinomaha。 - Joel
4个回答

73

简单回答:使用factory.sequence

如果你有一个需要唯一的字段,你可以在factory_girl中添加一个序列来确保它永远不会相同:

Factory.define :user do |user|
  sequence(:email){|n| "user#{n}@factory.com" }
  user.password{ "secret" }
end

这将每次递增n,以生成一个唯一的电子邮件地址,例如user52@factory.com。(有关更多信息,请参见https://github.com/thoughtbot/factory_girl/wiki/Usage
但在Rails.env.development中,这并不总是好的...
随着时间的推移,我发现这实际上不是创建唯一电子邮件地址的最有用的方法。原因是,虽然工厂始终是您的测试环境中的唯一项,但它并不总是您的开发环境中的唯一项,并且n会在启动和关闭环境时重置自己。在:test中,这不是问题,因为数据库被清除,但在:development中,您倾向于保留相同的数据一段时间。
然后你会遇到冲突,发现自己必须手动覆盖电子邮件为一些你知道是唯一的东西,这很烦人。
通常更有用的是:使用随机数
由于我经常从控制台调用u = Factory :user,所以我改为生成随机数。您不能保证避免冲突,但在实践中,它几乎从未发生过。
Factory.define :user do |user|
  user.email {"user_#{Random.rand(1000).to_s}@factory.com" }
  user.password{ "secret" }
end

注意,由于FactoryGirl中的冲突(错误?),您必须使用Random.rand而不是rand()(请参见https://github.com/thoughtbot/factory_girl/issues/219)。
这使您可以从命令行自由创建用户,而不管数据库中是否已有工厂生成的用户。
为了使电子邮件测试更容易,以下是额外的可选内容。
当您进行电子邮件测试时,通常希望验证特定用户的操作是否触发了向另一个用户发送的电子邮件。
您作为Robin Hood登录,向Maid Marion发送电子邮件,然后转到收件箱进行验证。您在收件箱中看到的是来自user_842@factory.com的内容。谁是那个人?
您需要返回到数据库,以检查电子邮件是否被发送/接收到您预期的人。再次这有点烦人。
我喜欢做的是使用工厂用户的名称和随机数字生成电子邮件。这使得更轻松地检查来源(并且还使碰撞变得极不可能)。使用Faker gem(http://faker.rubyforge.org/)创建名称,我们得到:
Factory.define :user do |user|
  user.first_name { Faker::Name::first_name }
  user.last_name { Faker::Name::last_name }
  user.email {|u| "#{u.first_name}_#{u.last_name}_#{Random.rand(1000).to_s}@factory.com" }
end

最后,由于Faker有时会生成不适合作为电子邮件的名称(例如Mike O'Donnell),我们需要将可接受的字符列入白名单:.gsub(/[^a-zA-Z1-10]/, '')

Factory.define :user do |user|
  user.first_name { Faker::Name::first_name }
  user.last_name { Faker::Name::last_name }
  user.email {|u| "#{u.first_name.gsub(/[^a-zA-Z1-10]/, '')}_#{u.last_name.gsub(/[^a-zA-Z1-10]/, '')}_#{Random.rand(1000).to_s}@factory.com" }
end

这使我们得到了亲切而独特的电子邮件,例如robin_hood_341@factory.commaid_marion_10@factory.com

2
然而,这种方法的缺点是电子邮件地址可能与名称不符。我现在明白你在这里想做什么了。另外,就个人而言,ffaker比经典的Faker更快、更好用。 - Marnen Laibow-Koser
1
然后使用 Faker::Internet.email("#{first_name} #{last_name}") 来使电子邮件与姓名匹配。 - docwhat
2
我想强调的是,传递块而不是参数是必要的,以避免模型属性的懒惰(一次性)生成。例如,在 user.email {"user_#{rand(1000).to_s}@factory.com" }; user.password{ "secret" } 中,{} 是必要的,以确保每次工厂构建用户时都会生成一个新的随机字符串。如果没有括号,相同的随机字符串将继续被重用。另一方面,在密码属性周围不需要使用括号。 - 123
1
使用 user.email {"user_#{rand(1000).to_s}@factory.com"} 会抛出错误:undefined method `rand='. 相反,应该使用:user.email {"user_#{Random.rand(1000)}@factory.com"}. 另外,#{} 是字符串插值,所以示例中的 .to_s 是多余的。 - Adam Kolkman
2
@MarnenLaibow-Koser 这些天ffaker并不更快。从他们的自述文件中可以看出:“自那时以来,faker也已经被重写,‘速度’因素现在可能是无关紧要的。” - doub1ejack
显示剩余3条评论

12

为了强制Factory Girl序列中的“n”与对象的id相同以避免冲突,我会这样做:

首先,在app/models/user.rb中定义一个方法来查找下一个id应该是什么:

def self.next_id
  self.last.nil? ? 1 : self.last.id + 1
end 

然后我从spec/factories.rb调用User.next_id来启动序列:

factory :user do
  association(:demo)
  association(:location)
  password  "password"
  sequence(:email, User.next_id) {|n| "darth_#{n}@sunni.ru" }
end

4
我发现这是一种确保测试始终通过的好方法。否则,您无法确定每次创建唯一电子邮件的百分比为100%。
FactoryGirl.define do
  factory :user do
    name { Faker::Company.name }
    email { generate(:email) }
  end
  sequence(:email) do
    gen = "user_#{rand(1000)}@factory.com"
    while User.where(email: gen).exists?
      gen = "user_#{rand(1000)}@factory.com"
    end
    gen
  end
end

2

如果您只需要为属性生成少量值,您也可以向String添加一个方法,该方法跟踪用于属性的先前字符串。然后您可以执行以下操作:

factory :user do
  fullname { Faker::Name.name.unique('user_fullname') }
end

我在种子处理中采用了这种方法。我想避免使用序列号,因为它们看起来不够真实。

下面是使其成为可能的字符串扩展:

class String
  # Makes sure that the current string instance is unique for the given id.
  # If you call unique multiple times on equivalent strings, this method will suffix it with a upcounting number.
  # Example:
  #     puts "abc".unique("some_attribute") #=> "abc"
  #     puts "abc".unique("some_attribute") #=> "abc-1"
  #     puts "abc".unique("some_attribute") #=> "abc-2"
  #     puts "abc".unique("other") #=> "abc"
  #
  # Internal: 
  #  We keep a data structure of the following format:
  #     @@unique_values = {
  #       "some_for_id" => { "used_string_1" : 1, "used_string_2": 2 } # the numbers represent the counter to be used as suffix for the next item
  #     }
  def unique(for_id)
    @@unique_values ||= {} # initialize structure in case this method was never called before
    @@unique_values[for_id] ||= {} # initialize structure in case we have not seen this id yet
    counter = @@unique_values[for_id][self] || 0
    result = (counter == 0) ? self : "#{self}-#{counter}"
    counter += 1
    @@unique_values[for_id][self] = counter
    return result
  end

end

注意:不应该用于大量属性,因为我们跟踪所有之前的字符串(可以进行优化)。


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