FactoryGirl build_stubbed策略与has_many关联

Jar*_*red 36 ruby-on-rails factory-bot

给定两个对象之间的标准has_many关系.举个简单的例子,我们来看看:

class Order < ActiveRecord::Base
  has_many :line_items
end

class LineItem < ActiveRecord::Base
  belongs_to :order
end
Run Code Online (Sandbox Code Playgroud)

我想要做的是生成带有存根行项目列表的存根订单.

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
Run Code Online (Sandbox Code Playgroud)

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

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

Aar*_*n K 116

TL; DR

FactoryGirl在创建它的"存根"对象时,通过做出一个非常大的假设来尝试提供帮助.也就是说: 你有一个id,这意味着你不是一个新的记录,因此已经存在!

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

请不要尝试垫片RSpec的存根/嘲笑成FactoryGirl工厂.这样做会在同一个对象上混合两种不同的存根理念.选择一个或另一个.

RSpec模拟仅应在规范生命周期的某些部分使用.将它们移到工厂会设置一个隐藏设计违规的环境.由此产生的错误将令人困惑并且难以追踪.

如果你看一下将RSpec包含在说测试/单元中的文档 ,你可以看到它提供了确保模拟在测试之间正确设置和拆除的方法.将模拟物放入工厂并不能保证这种情况发生.

这里有几个选项:

  • 不要使用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)
    
    Run Code Online (Sandbox Code Playgroud)

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

  • 清除id现场

    after(:stub) do |order, evaluator|
      order.id = nil
      order.line_items = build_stubbed_list(
        :line_item,
        evaluator.line_items_count,
        order: order
      )
    end
    
    Run Code Online (Sandbox Code Playgroud)
  • 创建自己的定义 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
    
    Run Code Online (Sandbox Code Playgroud)

这里发生了什么?

IMO,尝试创建"存根" has_many 关联通常不是一个好主意FactoryGirl.这往往会导致更紧密耦合的代码,并且可能会不必要地创建许多嵌套对象.

要了解这个位置,以及FactoryGirl的情况,我们需要看一些事情:

  • 数据库持久层/宝石(即ActiveRecord,Mongoid, DataMapper,ROM,等等)
  • 任何存根/模拟库(mintest/mocks,rspec,mocha等)
  • 目的模拟/存根服务

数据库持久层

每个数据库持久层的行为都不同.事实上,许多主要版本之间的行为有所不同.FactoryGirl 试图不对该层的设置方式做出假设.这使他们在长期内具有最大的灵活性.

假设:我猜你正在使用ActiveRecord这个讨论的其余部分.

在我写这篇文章时,目前的GA版本ActiveRecord是4.1.0.当你设置一个has_many它的关联, 一个 很多 .

在较旧的AR版本中,这也略有不同.它在Mongoid等方面有很大的不同.期望FactoryGirl理解所有这些宝石的复杂性,以及版本之间的差异是不合理的.事实上,该has_many协会的作者 试图保持最新的持久性.

你可能会想:"但我可以用存根设置反转"

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

li = build_stubbed(:line_item)
Run Code Online (Sandbox Code Playgroud)

是的,这是真的.虽然这只是因为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
Run Code Online (Sandbox Code Playgroud)

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
Run Code Online (Sandbox Code Playgroud)

是的,没错.我们可以设置order.line_items=为一个数组,它不会持久存在!什么给出了什么?

Stubbing/Mocking图书馆

有许多不同的类型,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
Run Code Online (Sandbox Code Playgroud)

由于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检查它的状态以决定是否应该 保持持久性最新.由于设置的存根定义new_record? 任何has_many将触发数据库操作.

def new_record?
  id.nil?
end
Run Code Online (Sandbox Code Playgroud)

在我抛弃一些修复之前,我想回到一个定义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
Run Code Online (Sandbox Code Playgroud)

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

另外,正如您所注意到的,FactoryGirl的"存根"抽象有点漏洞,迫使您了解其实现和数据库持久层的内部.使用stubbing 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)
Run Code Online (Sandbox Code Playgroud)

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

这是一个有真正的存根lib有助于明确清楚的事情.这是您的测试/规格,为您提供有关您的设计选择的反馈.通过这样的设置,规范的读者可以问一个问题:"为什么我们需要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
Run Code Online (Sandbox Code Playgroud)

我们永远不会有一个返回idAND 的存根设置一个has_many 关联.new_record?FactoryGirl设置的定义完全阻止了这一点.

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

在这里,我们将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
Run Code Online (Sandbox Code Playgroud)

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

  • 带有TL的惊人彻底和清晰的答案; DR!如果可以的话我会给你10票!谢谢! (2认同)

Bry*_*yce 11

我已经看到这个答案浮出水面,但遇到了同样的问题: FactoryGirl:填充一个有很多关系保留构建策略

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

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
Run Code Online (Sandbox Code Playgroud)

希望有所帮助!