FactoryGirl使用build_stubbed策略和has_many关联。

42

给定两个对象之间的标准 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的save方法,而FactoryGirl会抛出异常:RuntimeError: stubbed models are not allowed to access the database

那么如何才能生成一个被stub的对象,而它的has_many集合也是被stub的呢?


那么你的意思是说,stubbed表示你不想让它访问数据库?你这样做的目的是什么? - Mike Li
关于 order.stub(:line_items).and_return build_stubbed_list(...) 这段代码怎么样? - apneadiving
你是怎么做到将 RSpec 和 FactoryGirl 集成在一起的?因为我这样做时会出现“Order没有定义'stub'方法”的错误。 - Jared
1
你找到解决方案了吗? - Sherwin Yu
@Jared 如果答案对你有用,你应该接受它。 - kettlepot
显示剩余2条评论
3个回答

130

简而言之

FactoryGirl试图通过创建"stub"对象时做出一个非常大的假设来帮助你。这个假设是:

你有一个id,这意味着你不是一个新记录,因此已经存在!

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

请不要尝试将 RSpec stubs / mocks 引入 FactoryGirl 工厂中。这样做会在同一对象上混合两种不同的存根哲学。请选择其中之一。 RSpec mocks 只应在特定部分的规范生命周期中使用。将它们移到工厂中设置了一个环境,该环境将隐藏设计违规。由此产生的错误将令人困惑且难以跟踪。 如果您查看包括 RSpec 的文档,例如test/unit,则可以看到它提供了确保在测试之间正确设置和拆除模拟的方法。将模拟放入工厂中无法保证这将发生。
这里有几个选项:
  • Don't use FactoryGirl for creating your stubs; use a stubbing library (rspec-mocks, minitest/mocks, mocha, flexmock, rr, or etc)

    If you want to keep your model attribute logic in FactoryGirl that's fine. Use it for that purpose and create the stub elsewhere:

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

    Yes, you do have to manually create the associations. This is not a bad thing, see below for further discussion.

  • Clear the id field

    after(:stub) do |order, evaluator|
      order.id = nil
      order.line_items = build_stubbed_list(
        :line_item,
        evaluator.line_items_count,
        order: order
      )
    end
    
  • Create your own definition of 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
    

这里发生了什么?

在我看来,使用FactoryGirl尝试创建“存根”has_many关联通常不是一个好主意。这往往导致代码耦合性更高,可能会需要创建许多嵌套对象。

为了理解这个观点以及FactoryGirl中发生的事情,我们需要看一些东西:

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

数据库持久化层

每个数据库持久化层的行为都不同。事实上,许多在主要版本之间的行为也不同。FactoryGirl 尽量不对该层的设置进行假设。这使他们在长期内具有最大的灵活性。

假设: 我猜您在本文剩余部分使用ActiveRecord

截至我写下这篇文章,ActiveRecord 的当前 GA 版本为 4.1.0。当你在其上设置一个 has_many 关联时,有很多代码需要处理,涉及的文件包括:associations.rbhas_many.rbcollection_association.rbassociation.rb,还有 has_many_association.rbassociation.rb
这在旧版 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添加对象而不必访问数据库”。

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交互的维基页面上所述:

...[一个工厂]首先保存关联,以便外键将正确设置在相关模型上。要创建一个实例,它调用没有任何参数的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没有使用你喜欢的库,它在做什么呢?

模拟/桩的用途

在我们深入了解内部细节之前,我们需要定义what a “stub” is以及它的intended purpose

存根为测试期间进行的调用提供预先制定的答案,通常不会对程序中未编程的任何内容做出响应。存根还可以记录有关调用的信息,例如电子邮件网关存根记住它“发送”的消息,或者仅记住它“发送”的消息数量。

这与“mock”略有不同:

Mock:预先编程的对象,具有期望的规范,形成它们所期望接收的调用的规范。

存根(Stubs)作为一种设置协作者带有预设响应的方法。仅使用你为特定测试所触及的协作者公共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在其“stubs”方面完全不涉及库,因此这是他们采取的方法

查看FactoryGirl v.4.4.0实现,我们可以看到以下方法在使用build_stubbed时都被存根:

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

这些都非常类似于ActiveRecord。但是,正如您在has_many中看到的那样,它是一个相当泄漏的抽象。 ActiveRecord的公共API表面积非常大。不太可能期望库完全覆盖它。

为什么has_many关联与FactoryGirl stub不起作用?

正如上面所提到的,ActiveRecord检查其状态以决定是否应该保持持久性最新。由于new_record?的存根定义, 设置任何has_many都会触发数据库操作。
def new_record?
  id.nil?
end

在我提供解决方法之前,我想先回到对stub的定义:

stub提供了测试调用时的预设答案,通常只响应测试程序中编好的内容。Stubs也可以记录调用的信息,例如一个电子邮件网关的stub可以记住它“发送”的消息,或者只是记录它“发送”了多少条消息。

FactoryGirl的stub实现违反了这一原则。因为它不知道你在测试中会做什么,所以只是试图防止数据库访问。

解决方案1:不要使用FactoryGirl创建stubs

如果您想创建/使用存根,请使用专门用于此任务的库。由于您似乎已经在使用RSpec,请使用其double功能(以及RSpec 3中的新验证instance_doubleclass_doubleobject_double)。或者使用Mocha、Flexmock、RR或其他任何东西。
您甚至可以自己制作一个超级简单的存根工厂(是的,这存在问题,它只是一个使用预设响应来创建对象的简单示例):
require 'ostruct'
def create_stub(stubbed_attributes)
  OpenStruct.new(stubbed_attributes)
end

FactoryGirl使得创建100个模型对象非常容易,即使你只需要一个。当然,这是一个责任使用问题;伟大的力量总是伴随着责任。很容易忽略深度嵌套的关联,它们不应该在存根中存在。
此外,正如您已经注意到的那样,FactoryGirl的“stub”抽象有点泄漏,强制您了解其实现和数据库持久性层的内部工作原理。使用存根库应该完全使您摆脱这种依赖。
如果您想将模型属性逻辑保留在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个行项目?" 如果它对规范很重要,那么很好,它就在那里,清晰明了。否则,它不应该存在。

同样的道理也适用于那些调用单个对象上长链式方法或后续对象上的方法链,现在可能是时候停下来了。迪米特法则是为了帮助你而不是阻碍你。

修复 #2:清除id字段

这更像是一个hack。我们知道默认的存根会设置一个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

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


2
非常详细和清晰的回答,带有 TL;DR!如果可以的话,我会给你10个赞!谢谢! - Paul Pettengill
1
从未真正考虑过这个解决方案,但你很好地解释了这种情况,亚伦。感谢您将此页面作为每个人的资源! - Jared
@Aaron K 的帖子非常棒,但是有些链接找不到或者已经失效了。能否请您重新检查一下?谢谢。 - Kick Buttowski
杰出的回答。非常有帮助。 - Anony-mouse

12

我看到了这个答案,但遇到了你遇到的同样的问题: 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

希望这能有所帮助!


真的很有帮助!谢谢。 - weston

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
...

enable_expect 是一个私有 API,请不要使用它。RSpec mocks 只允许在 RSpec 生命周期的特定部分中使用。这种用法会导致进一步的问题。FactoryGirl 的 stub 策略与 RSpec stubs 没有任何关系。它们只是因为共享了代表相同“stub”概念的名称而被称作相同。 - Aaron K
如果你真的想要这个语法,虽然我建议你避免使用它,那么公共API的方法是:FactoryGirl::SyntaxRunner.include(RSpec::Mocks::ExampleMethods)。关于此的文档可以在以下链接找到:https://www.relishapp.com/rspec/rspec-mocks/v/3-0/docs/test-frameworks/test-unit-integration - Aaron K

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