如何在Factory Girl或Minifacture中创建随机唯一数据的Ruby测试工厂?

9

我正在测试一个典型的Rails模型,使用典型的工厂:

# My model uses a 3-letter uppercase airport code,
# such as "ATL" for Atlanta, "BOS" for Boston, etc.

class Airport < ActiveRecord::Base
  validates :code, uniqueness: true

Factory.define :airport do |f|
  f.code { random_airport_code }  # Get a 3-letter uppercase code

我正在添加更多的测试,并开始看到机场代码冲突:例如工厂使用代码“XYZ”创建一个机场,然后对工厂的后续调用尝试创建具有相同代码的机场。
一种解决方法是使用序列。例如使用Factory Girl序列、有序列表、预先计算的枚举或者维护下一个可用代码状态的类似方式。
我的问题是:有哪些非序列化的方法可以解决这个问题?我想使用随机数据,而不是序列。
以下是我正在尝试的一些实用的想法-如果您能提供任何见解,将不胜感激。
使用乐观锁定的示例思路。
while 
  airport = Factory.build :airport
  airport.save && return airport
end

优点:因为碰撞很少,实际速度快;本地状态。

缺点:语法笨拙;不局限于工厂;保存可能失败,原因不仅是碰撞。

使用事务的示例构想

Airport.transaction 
  while
    x = random_airport_code
    if Airport.exists?(code: x)
      next
    else
      Factory :airport, code: x
      break
    end
  end
end

优点:这是最接近我想要的;本地状态;确保没有冲突。

缺点:语法冗长而尴尬。

Bounty

Factory Girl或Minifacture是否有更适合随机数据而非顺序的任何语法?

或者,是否有一种模式可以自动重新投掷骰子,如果存在保存冲突?

对我来说,有些额外开销是可以接受的。在实践中,每天在数千个测试的持续集成设置中发生一次冲突左右。如果测试套件必须重新投掷几次骰子,或者探测数据库中已存在的值等,那就没关系。

评论问为什么使用随机数据而不是序列。我更喜欢随机数据,因为我的经验是随机数据可以带来更好的测试、更好的长期可维护性和更好的测试目标语义。此外,我使用Faker和Forgery代替固定装置,如果这有助于了解的话。

要获得赏金,答案必须是随机生成 - 不是一个序列。(例如,我正在寻找的解决方案可能会使用#sample和/或无序集合,并且可能不使用#shuffle和/或有序集合)


随机数据相对于序列数据的优势是什么?在测试中使用随机数据可能会导致间歇性故障,这些故障很难诊断或重现。 - georgebrock
也许你想看一下 Faker(https://github.com/stympy/faker)- 你可以使用 Faker :: Lorem.characters(3),并将它们转换为大写字母用于测试。 - Georg Keferböck
我不知道你想要实现什么。如果你需要“随机”数据,faker 应该已经足够了。如果你想避免冲突,使用序列应该也足够了。最坏的情况下,你可以结合 faker 和序列来生成始终“随机”的数据。 - phoet
生成代码的更快方法:1.upto(3).inject("") { |m, e| m << (rand(26)+65).chr} - zetetic
@zetetic 谢谢,我会尝试为此进行基准测试。 - joelparkerhenderson
显示剩余3条评论
4个回答

7
您可以使用回调函数,类似于以下内容:
factory :airport do
  after(:build) do |airport|
    airport.code = loop do
      code = ('AAA'..'ZZZ').to_a.sample
      break code unless Airport.exists?(code: code)
    end
  end
end

你可能需要将after(:build)改为before(:create),具体取决于你想如何使用工厂。

谢谢Gergo,你的回答最接近我的目标,即在极少出现冲突的情况下进行重试以获得真正的随机性。非常感谢! - joelparkerhenderson

3

这应该可以工作,但它只允许创建17576个模型

CODES = ("AAA".."ZZZ").to_a.shuffle
Factory.define :airport do |f|
  f.code { CODES.pop }
end

谢谢phoet。我正在寻找一种不使用序列的解决方案,换句话说,不需要CODES数组,因为它是非本地状态。 - joelparkerhenderson
这个成员足够本地吗?{ (@codes ||= ("AAA".."ZZZ").to_a.shuffle).pop } 我真的很喜欢你如何解决这样一个微不足道的问题。 - phoet
像您评论中提到的成员仍将存储一个序列,即一个有序集合,它知道每个下一个元素并随时间维护其状态。我的目标是每次使用随机选择,而不是序列。 - joelparkerhenderson
在可能值从AAA到ZZZ的极限空间中,可用的随机性非常少。你如何在没有开销的情况下实现碰撞检测呢? - phoet
我对额外开销没意见——我会将其添加到问题陈述中。每个测试都会清除数据库,并且每个测试需要一些机场才能正确运行,因此实际值只是可能值空间的一个小区域,即我正在从17576个选项中选择5个。 - joelparkerhenderson

1

是的,FactoryGirl有一个功能可以让您这样做。请参阅序列文档的末尾:https://github.com/thoughtbot/factory_girl/blob/master/GETTING_STARTED.md#sequences 您可以将序列设置为任何对象,只要该对象知道在其上调用#next时如何返回自身的递增版本。因此,您可以编写一个知道如何返回唯一随机数据并实现#next的类,例如:

class AirportCode
  ALL = %w(AAA BBB CCC).shuffle

  attr_reader :index

  def initialize(index = rand(ALL.length))
    @index = index
  end

  def value
    ALL[@index]
  end

  def to_s
    value
  end

  # might need to explicitly delegate more methods to the value

  def method_missing(method, *args)
    value.send method, *args
  end

  def next
    AirportCode.new((index + 1) % ALL.length)
  end

end

(这个只有三个唯一值,但只是为了说明问题),创建一个FactoryGirl序列并将其值设置为该类的实例。我没有尝试过FactoryGirl部分,所以如果它有效,请回报:)


谢谢,戴夫。我正在寻找一种不使用序列的解决方案。 - joelparkerhenderson
仍然不清楚为什么factory_girl的sequence构造不应该成为解决方案的一部分。我知道你不想要顺序数据,但是如果你可以欺骗factory_girl序列返回非顺序数据,就像上面那样,为什么不呢? - Dave Schweisguth
因为你的答案中的序列仍然是一个序列,即仍需要维护列表顺序并跟踪索引。这本质上与问题中的“预先计算序列”示例相同。我正在寻求一种无需在代码中跟踪状态的解决方案。(例如,理想的解决方案将适用于分布式设置,在该设置中,工厂运行在不同的机器上,所有机器都与同一个数据库通信) - joelparkerhenderson

1

与 @GergoErdosi 的答案类似,我成功地使这个工作了:

CODES = ("AAA".."ZZZ").to_a.shuffle

工厂 :airport do after(:build) do |airport| if Airport.exists?(code: airport.code) new_code = ('AAA'..'ZZZ').to_a.sample airport.code = new_code end end code { CODES.rotate!.first } ... #其他用于构建机场的东西 end


1
谢谢回复。我相信你的答案的核心价值在于GergoErdosi的after(:build)重试和我的示例中的#rotate的混合使用。 - joelparkerhenderson

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