40

给定两个对象之间的标准 has_many 关系。举个简单的例子,让我们一起去:

class Order < ActiveRecord::Base
  has_many :line_items
end

class LineItem < ActiveRecord::Base
  belongs_to :order
end

我想做的是生成一个带有存根订单项列表的存根订单。

FactoryGirl.define do
  factory :line_item do
    name 'An Item'
    quantity 1
  end
end

FactoryGirl.define do
  factory :order do
    ignore do
      line_items_count 1
    end

    after(:stub) do |order, evaluator|
      order.line_items = build_stubbed_list(:line_item, evaluator.line_items_count, :order => order)
    end
  end
end

上面的代码不起作用,因为 Rails 想要在分配 line_items 并且 FactoryGirl 引发异常时对订单调用 save: RuntimeError: stubbed models are not allowed to access the database

那么你如何(或者有可能)生成一个存根对象,它的 has_may 集合也是存根的?

4

3 回答 3

126

TL;博士

FactoryGirl 试图通过在创建它的“存根”对象时做出非常大的假设来提供帮助。即: 你有一个id,这意味着你不是一个新记录,因此已经被持久化了!

不幸的是,ActiveRecord 使用它来决定它是否应该 保持持久性是最新的。因此,存根模型尝试将记录持久保存到数据库中。

请不要尝试将 RSpec 存根/模拟填充到 FactoryGirl 工厂中。这样做会在同一个对象上混合两种不同的存根哲学。选择一个或另一个。

RSpec 模拟只应该在规范生命周期的某些部分使用。将它们移入工厂设置了一个环境,该环境将隐藏违反设计的行为。由此产生的错误将令人困惑且难以追踪。

如果您查看将 RSpec 包含在 say test/unit中的文档,您会发现它提供了确保在测试之间正确设置和拆除模拟的方法。将模拟物放入工厂并不能保证会发生这种情况。

这里有几个选项:

  • 不要使用 FactoryGirl 创建存根;使用存根库(rspec-mocks、minitest/mocks、mocha、flexmock、rr 等)

    如果您想将模型属性逻辑保留在 FactoryGirl 中,那很好。为此目的使用它并在其他地方创建存根:

    stub_data = attributes_for(:order)
    stub_data[:line_items] = Array.new(5){
      double(LineItem, attributes_for(:line_item))
    }
    order_stub = double(Order, stub_data)
    

    是的,您必须手动创建关联。这不是一件坏事,请参阅下面的进一步讨论。

  • 清除id字段

    after(:stub) do |order, evaluator|
      order.id = nil
      order.line_items = build_stubbed_list(
        :line_item,
        evaluator.line_items_count,
        order: order
      )
    end
    
  • 创建您自己的定义new_record?

    factory :order do
      ignore do
        line_items_count 1
        new_record true
      end
    
      after(:stub) do |order, evaluator|
        order.define_singleton_method(:new_record?) do
          evaluator.new_record
        end
        order.line_items = build_stubbed_list(
          :line_item,
          evaluator.line_items_count,
          order: order
        )
      end
    end
    

这里发生了什么?

IMO,尝试has_manyFactoryGirl. 这往往会导致更紧密耦合的代码,并可能会不必要地创建许多嵌套对象。

要了解这个立场,以及 FactoryGirl 发生了什么,我们需要看一些事情:

  • 数据库持久层/gem(即ActiveRecord、、、、Mongoid等 )DataMapperROM
  • 任何存根/模拟库(mintest/mocks、rspec、mocha 等)
  • 模拟/存根服务的目的

数据库持久层

每个数据库持久层的行为都不同。事实上,许多主要版本之间的行为不同。FactoryGirl尽量不对该层的设置方式做出假设。从长远来看,这为他们提供了最大的灵活性。

假设:我猜你在ActiveRecord本次讨论的其余部分使用。

在我写这篇文章时,当前的 GA 版本ActiveRecord是 4.1.0。当您在其上设置has_many关联 发生 很多 事情

这在较旧的 AR 版本中也略有不同。它在 Mongoid 等中非常不同。期望 FactoryGirl 理解所有这些 gem 的复杂性以及版本之间的差异是不合理的。碰巧该has_many协会的作者 试图使持久性保持最新

你可能会想:“但我可以用存根设置逆”

FactoryGirl.define do
  factory :line_item do
    association :order, factory: :order, strategy: :stub
  end
end

li = build_stubbed(:line_item)

是的,这是真的。虽然这仅仅是因为 AR 决定 坚持。事实证明,这种行为是一件好事。否则,在不频繁访问数据库的情况下设置临时对象将非常困难。此外,它允许将多个对象保存在单个事务中,如果出现问题,则回滚整个事务。

现在,您可能在想:“我完全可以在has_many不访问数据库的情况下向 a 添加对象”

order = Order.new
li = order.line_items.build(name: 'test')
puts LineItem.count                   # => 0
puts Order.count                      # => 0
puts order.line_items.size            # => 1

li = LineItem.new(name: 'bar')
order.line_items << li
puts LineItem.count                   # => 0
puts Order.count                      # => 0
puts order.line_items.size            # => 2

li = LineItem.new(name: 'foo')
order.line_items.concat(li)
puts LineItem.count                   # => 0
puts Order.count                      # => 0
puts order.line_items.size            # => 3

order = Order.new
order.line_items = Array.new(5){ |n| LineItem.new(name: "test#{n}") }
puts LineItem.count                   # => 0
puts Order.count                      # => 0
puts order.line_items.size            # => 5

是的,但这里order.line_items真的是一个 ActiveRecord::Associations::CollectionProxy. 它定义了它自己的build, #<<, 和#concat 方法。当然,这些实际上都委托给定义的关联,它们has_many是等效的方法: ActiveRecord::Associations::CollectionAssocation#buildActiveRecord::Associations::CollectionAssocation#concat. 这些考虑了基本模型实例的当前状态,以便决定是现在还是以后持久化。

FactoryGirl 在这里真正能做的就是让底层类的行为定义应该发生的事情。事实上,这让您可以使用 FactoryGirl 生成任何类,而不仅仅是数据库模型。

FactoryGirl 确实尝试在保存对象方面提供一些帮助。这主要是在create工厂方面。根据他们关于 与 ActiveRecord 交互的 wiki 页面:

...[a factory] ​​首先保存关联,以便在依赖模型上正确设置外键。要创建一个实例,它会调用不带任何参数的 new,分配每个属性(包括关联),然后调用 save!。factory_girl 没有做任何特别的事情来创建 ActiveRecord 实例。它不会以任何方式与数据库交互或扩展 ActiveRecord 或您的模型。

等待!您可能已经注意到,在上面的示例中,我遗漏了以下内容:

order = Order.new
order.line_items = Array.new(5){ |n| LineItem.new(name: "test#{n}") }
puts LineItem.count                   # => 0
puts Order.count                      # => 0
puts order.line_items.size            # => 5

是的,没错。我们可以设置order.line_items=一个数组并且它不会被持久化!那么给了什么?

存根/模拟库

有许多不同的类型,FactoryGirl 都适用于它们。为什么?因为 FactoryGirl 对它们中的任何一个都不做任何事情。它完全不知道您拥有哪个库。

请记住,您将 FactoryGirl 语法添加到您选择的测试库中。您不会将您的库添加到 FactoryGirl。

因此,如果 FactoryGirl 没有使用您喜欢的库,它在做什么?

目的模拟/存根服务

在深入了解底层细节之前,我们需要定义什么 “存根” 及其预期用途

存根为测试期间拨打的电话提供预设答案,通常根本不响应任何超出测试程序的内容。存根还可以记录有关呼叫的信息,例如记住它“发送”的消息的电子邮件网关存根,或者可能只记录它“发送”的消息的数量。

这与“模拟”略有不同:

模拟...:预先编程的对象,形成他们期望接收的调用的规范。

存根是一种通过预设响应设置协作者的方式。只坚持你为特定测试接触的合作者公共 API 可以保持存根轻量级和小型化。

没有任何“存根”库,您可以轻松创建自己的存根:

stubbed_object = Object.new
stubbed_object.define_singleton_method(:name) { 'Stubbly' }
stubbed_object.define_singleton_method(:quantity) { 123 }

stubbed_object.name       # => 'Stubbly'
stubbed_object.quantity   # => 123

由于 FactoryGirl 在其“存根”方面完全与库无关,因此这是他们采用的方法

查看 FactoryGirl v.4.4.0 的实现,我们可以看到以下方法都被存根了build_stubbed

  • persisted?
  • new_record?
  • save
  • destroy
  • connection
  • reload
  • update_attribute
  • update_column
  • created_at

这些都是非常ActiveRecord-y。但是,正如您在 中看到的那样has_many,它是一个相当有漏洞的抽象。ActiveRecord 公共 API 表面积非常大。期望一个库完全覆盖它是不完全合理的。

为什么该has_many关联不适用于 FactoryGirl 存根?

如上所述,ActiveRecord 检查它的状态以确定它是否应该 保持持久性是最新的。由于 设置 any的存根定义new_record?has_many将触发数据库操作。

def new_record?
  id.nil?
end

在我抛出一些修复之前,我想回到 a 的定义stub

存根为测试期间拨打的电话提供预设答案,通常根本不响应任何超出测试程序的内容。存根还可以记录有关呼叫的信息,例如记住它“发送”的消息的电子邮件网关存根,或者可能只记录它“发送”的消息的数量。

存根的 FactoryGirl 实现违反了这一原则。由于它不知道您将在测试/规范中做什么,它只是试图阻止数据库访问。

修复 #1:不要使用 FactoryGirl 创建存根

如果您希望创建/使用存根,请使用专用于该任务的库。由于您似乎已经在使用 RSpec,请使用它的double功能(以及新的验证 instance_double, class_double以及object_double RSpec 3 中的)。或者使用 Mocha、Flexmock、RR 或其他任何东西。

你甚至可以推出你自己的超级简单的存根工厂(是的,这有问题,这只是一个简单的方法来制作带有预设响应的对象的示例):

require 'ostruct'
def create_stub(stubbed_attributes)
  OpenStruct.new(stubbed_attributes)
end

FactoryGirl 可以在您真正需要时轻松创建 100 个模型对象 1。当然,这是一个负责任的使用问题;一如既往,强大的力量来创造责任。很容易忽略深度嵌套的关联,这些关联并不真正属于存根。

此外,正如您所注意到的,FactoryGirl 的“存根”抽象有点泄漏,迫使您了解它的实现和数据库持久层的内部结构。使用存根库应该完全使您摆脱这种依赖关系。

如果您想将模型属性逻辑保留在 FactoryGirl 中,那很好。为此目的使用它并在其他地方创建存根:

stub_data = attributes_for(:order)
stub_data[:line_items] = Array.new(5){
  double(LineItem, attributes_for(:line_item))
}
order_stub = double(Order, stub_data)

是的,您必须手动设置关联。尽管您只设置了测试/规范所需的那些关联。你没有得到你不需要的其他 5 个。

拥有一个真正的存根库有助于明确说明这一点。这是您的测试/规格,可为您提供有关设计选择的反馈。通过这样的设置,规范的读者可以提出以下问题:“为什么我们需要 5 个订单项?” 如果它对规范很重要,那么它就在前面并且显而易见。否则,它不应该在那里。

对于那些称为单个对象的长链方法或后续对象的方法链也是如此,可能是时候停止了。得 墨忒耳法则可以帮助你,而不是阻碍你。

修复 #2:清除id字段

这更像是一种黑客行为。我们知道默认存根设置了一个id. 因此,我们只需将其删除。

after(:stub) do |order, evaluator|
  order.id = nil
  order.line_items = build_stubbed_list(
    :line_item,
    evaluator.line_items_count,
    order: order
  )
end

我们永远不可能有一个返回idAND 建立has_many 关联的存根。FactoryGirl 设置的定义new_record?完全阻止了这种情况。

修复#3:创建你自己的定义new_record?

在这里,我们将 a 的概念id与存根是 a 的地方 分开new_record?。我们将其推送到一个模块中,以便我们可以在其他地方重用它。

module SettableNewRecord
  def new_record?
    @new_record
  end

  def new_record=(state)
    @new_record = !!state
  end
end

factory :order do
  ignore do
    line_items_count 1
    new_record true
  end

  after(:stub) do |order, evaluator|
    order.singleton_class.prepend(SettableNewRecord)
    order.new_record = evaluator.new_record
    order.line_items = build_stubbed_list(
      :line_item,
      evaluator.line_items_count,
      order: order
    )
  end
end

我们仍然需要为每个模型手动添加它。

于 2014-04-25T04:03:54.493 回答
12

我已经看到了这个答案,但遇到了同样的问题: FactoryGirl: Populate a has many relationship保留构建策略

我发现的最干净的方法是显式地存根关联调用。

require 'rspec/mocks/standalone'

FactoryGirl.define do
  factory :order do
    ignore do
      line_items_count 1
    end

    after(:stub) do |order, evaluator|
      order.stub(line_items).and_return(FactoryGirl.build_stubbed_list(:line_item, evaluator.line_items_count, :order => order))
    end
  end
end

希望有帮助!

于 2013-08-26T16:36:27.017 回答
1

我发现 Bryce 的解决方案是最优雅的,但它会产生关于新allow()语法的弃用警告。

为了使用新的(更干净的)语法,我这样做了:

更新 06/05/2014 :我的第一个提议是使用私有 api 方法,感谢 Aaraon K 提供了更好的解决方案,请阅读评论以进行进一步讨论

#spec/support/initializers/factory_girl.rb
...
#this line enables us to use allow() method in factories
FactoryGirl::SyntaxRunner.include(RSpec::Mocks::ExampleMethods)
...

 #spec/factories/order_factory.rb
...
FactoryGirl.define do
  factory :order do
    ignore do
      line_items_count 1
    end

    after(:stub) do |order, evaluator|
      items = FactoryGirl.build_stubbed_list(:line_item, evaluator.line_items_count, :order => order)
      allow(order).to receive(:line_items).and_return(items)
    end
  end
end
...
于 2014-04-24T11:29:21.960 回答