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_many
与FactoryGirl
. 这往往会导致更紧密耦合的代码,并可能会不必要地创建许多嵌套对象。
要了解这个立场,以及 FactoryGirl 发生了什么,我们需要看一些事情:
- 数据库持久层/gem(即
ActiveRecord
、、、、Mongoid
等
)DataMapper
ROM
- 任何存根/模拟库(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#build
和ActiveRecord::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
我们永远不可能有一个返回id
AND 建立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
我们仍然需要为每个模型手动添加它。