简而言之
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(即
ActiveRecord
、Mongoid
、DataMapper
、ROM
等)
- 任何存根/模拟库(mintest/mocks、rspec、mocha等)
- 存根/模拟服务的目的
数据库持久化层
每个数据库持久化层的行为都不同。事实上,许多在主要版本之间的行为也不同。FactoryGirl 尽量不对该层的设置进行假设。这使他们在长期内具有最大的灵活性。
假设: 我猜您在本文剩余部分使用ActiveRecord
。
截至我写下这篇文章,
ActiveRecord
的当前 GA 版本为 4.1.0。当你在其上设置一个
has_many
关联时,有很多代码需要处理,涉及的文件包括:
associations.rb、
has_many.rb、
collection_association.rb 和
association.rb,还有
has_many_association.rb 和
association.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
puts Order.count
puts order.line_items.size
li = LineItem.new(name: 'bar')
order.line_items << li
puts LineItem.count
puts Order.count
puts order.line_items.size
li = LineItem.new(name: 'foo')
order.line_items.concat(li)
puts LineItem.count
puts Order.count
puts order.line_items.size
order = Order.new
order.line_items = Array.new(5){ |n| LineItem.new(name: "test#{n}") }
puts LineItem.count
puts Order.count
puts order.line_items.size
是的,但这里的order.line_items
实际上是一个ActiveRecord::Associations::CollectionProxy
。它定义了自己的build
、#<<
和#concat
方法。当然,这些方法实际上都代理到定义的关联,对于has_many
来说,等效的方法是:ActiveRecord::Associations::CollectionAssocation#build
和ActiveRecord::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
puts Order.count
puts order.line_items.size
没错,我们可以将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
stubbed_object.quantity
由于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_double
,
class_double
和
object_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
我们仍然需要为每个模型手动添加它。
order.stub(:line_items).and_return build_stubbed_list(...)
这段代码怎么样? - apneadiving