FactoryGirl build_stubbed策略与has_many关联

时间:2013-07-19 20:17:31

标签: ruby-on-rails factory-bot

给定两个对象之间的标准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时调用order并且FactoryGirl引发异常: RuntimeError: stubbed models are not allowed to access the database

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

3 个答案:

答案 0 :(得分:111)

TL; DR

FactoryGirl尝试通过做出非常大的假设来提供帮助 创造它&#34; stub&#34;对象。即:那: you have an id, which means you are not a new record, and thus are already persisted!

不幸的是,ActiveRecord使用它来决定它是否应该 keep persistence up to date。 因此,存根模型会尝试将记录保存到数据库中。

  

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

     

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

     

如果你看一下包含RSpec的文件说   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,尝试创建一个&#34; stubbed&#34;一般不是一个好主意。 has_manyFactoryGirl的关联。这往往会导致更紧密耦合的代码 并且可能会不必要地创建许多嵌套对象。

要理解这个位置,以及FactoryGirl的情况,我们需要 看看几件事:

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

数据库持久层

每个数据库持久层的行为都不同。事实上,许多人表现得很好 主要版本之间不同。 FactoryGirl 尝试不做出假设 关于如何设置该层。这给了他们最大的灵活性 长途运输。

假设:我猜测你正在使用ActiveRecord剩下的 这个讨论。

在我写这篇文章时,ActiveRecord的当前GA版本是4.1.0。什么时候 你在它上面设置了一个has_many关联, there's a lot that goes on

在较旧的AR版本中,这也略有不同。它有很大的不同 Mongoid等等。期望FactoryGirl理解它是不合理的 所有这些宝石的复杂性,以及版本之间的差异。就是这样 发生了has_many association's writer 尝试keep persistence up to date

你可能会想:&#34;但我可以用存根&#34;

设置反转
FactoryGirl.define do
  factory :line_item do
    association :order, factory: :order, strategy: :stub
  end
end

li = build_stubbed(:line_item)
是的,这是真的。虽然这只是因为AR决定not to persist。 事实证明这种行为是一件好事。否则,它会非常 很难设置临时对象而不经常访问数据库。 此外,它允许将多个对象保存在一个对象中 事务,如果出现问题,则回滚整个事务。

现在,您可能会想:&#34;我完全可以在没有has_many的情况下添加对象 点击数据库&#34;

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 generate any class,不是 只是数据库模型。

FactoryGirl尝试帮助保存对象。这主要是 在create工厂的一侧。根据他们的维基页面 interaction with ActiveRecord

  

... [工厂]首先保存关联,以便正确使用外键   在依赖模型上设置。要创建实例,它将调用new而不使用任何实例   参数,分配每个属性(包括关联),然后调用   保存!。 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=设置为数组,但它不是 坚持!那是什么给出了什么?

Stubbing / Mocking Libraries

有许多不同的类型,FactoryGirl与它们一起使用。为什么? 因为FactoryGirl对它们中的任何一个都没有做任何事情。它完全是 不知道你有哪个图书馆。

请记住,您将FactoryGirl语法添加到test library of choice。 您不能将您的库添加到FactoryGirl。

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

目的模拟/存根服务

在我们了解详情之前,我们需要定义what a "stub" is 及其intended purpose

  

存根为测试期间拨打的电话提供固定答案,通常不会   完全响应任何超出测试编程范围的任何东西。   存根还可以记录有关呼叫的信息,例如电子邮件网关存根   它会记住它发送的消息,或者可能只消息它有多少消息   &#39;发送&#39;

这与&#34; mock&#34;:

略有不同
  

模拟 ...:预先编程的对象形成了一个期望   他们期望收到的电话的规范。

Stubs可以作为一种使用预设回复设置协作者的方法。坚持 只有您为特定测试触摸的协作者公共API保留 存根轻巧小巧。

没有任何&#34;存根&#34;库,您可以轻松创建自己的存根:

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完全与库无关 &#34;存根&#34;,这是the approach they take

看看FactoryGirl v.4.4.0的实现,我们可以看到了 当build_stubbed

时,以下方法都是存根的
  • persisted?
  • new_record?
  • save
  • destroy
  • connection
  • reload
  • update_attribute
  • update_column
  • craeted_at

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

为什么has_many关联不能与FactoryGirl存根一起使用?

如上所述,ActiveRecord会检查它的状态以决定是否应该这样做 keep persistence up to date。 由于stubbed definition of new_record? 设置任何has_many将触发数据库操作。

def new_record?
  id.nil?
end

在我抛弃一些修正之前,我想回到stub的定义:

  

Stubs为测试期间拨打的电话提供固定答案,通常不是   完全响应任何超出测试编程范围的任何东西。   存根还可以记录有关呼叫的信息,例如电子邮件网关存根   它会记住它发送的消息,或者可能只消息它有多少消息   &#39;发送&#39;

存根的FactoryGirl实现违反了这一原则。既然没有 想一想你将在测试/规范中做什么,它只是试图 阻止数据库访问。

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

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

你甚至可以推出自己的超级简单存根工厂(是的,有问题 这只是一个用罐头制作物品的简单方法的一个例子 响应):

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

FactoryGirl让您在创建100个模型对象时非常容易 需要1.当然,这是一个负责任的使用问题;一如既往的强大力量 创造责任。它很容易被忽视深层嵌套 协会,它们并不真正属于存根。

此外,正如您所注意到的,FactoryGirl&#34; stub&#34;抽象有点 泄漏迫使您了解其实现和数据库 持久层的内部。使用存根lib应该完全解放你 从而具有这种依赖性。

如果你想让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个 那些你不需要的。

有一个真正的存根lib有助于明确说明。 这是您的测试/规格,为您提供有关您的设计选择的反馈。有了 像这样的设置,规范的读者可以提出这样的问题:&#34;为什么我们需要5 订单项?&#34; 如果它对规范很重要,那么它就在前面很棒 而且很明显。否则,它不应该在那里。

同样的事情适用于那些称为单个对象的长链方法, 或后续对象的一系列方法,可能是时候停止了。该 law of demeter可以提供帮助 你,不要阻碍你。

修复#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

我们永远不会有一个返回id的存根并设置has_many 协会。 FactoryGirl完全设置new_record?的定义 防止这种情况。

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

定义

在这里,我们将id的概念与存根所在的概念分开 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

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

答案 1 :(得分:11)

我看到这个答案浮出水面,但遇到了同样的问题: FactoryGirl: Populate a has many relation preserving build strategy

我发现最干净的方法是明确地删除关联调用。

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

希望有所帮助!

答案 2 :(得分: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
...