繁体   English   中英

如何正确模拟 Active Record model 的实例以测试是否已调用方法

[英]How to properly mock an instance of an Active Record model to test that a method has been called

通过以下 controller 操作:

def create
  my_foo = MyFoo.find params[:foo_id]

  my_bar = my_foo.my_bars.create! my_bar_params
  my_bar.send_notifications

  redirect_to my_bar
end

在我的测试中,我试图断言方法send_notifications ) 在my_bar中被调用,它是 AR model 的一个实例。

测试这一点的一种方法是确保发送通知( expect { request}.to enqueue_mail... )。 但这可能不是一个好的做法,因为它会穿透抽象层。

另一种选择是使用expect_any_instance_of

it 'sends the notifications' do
  expect_any_instance_of(MyBar).to receive :send_notifications

  post my_bars_path, params: { my_bar: valid_attributes }
end

我喜欢这种方法,因为它简洁明了,但 RSpec 的创建者似乎弃用了它。

我尝试的另一种方法需要 mocking 很多 AR 方法:

it 'sends the notifications' do
  my_bar = instance_double MyBar

  allow(MyBar).to receive(:new).and_return my_bar
  allow(my_bar).to receive :_has_attribute?
  allow(my_bar).to receive :_write_attribute
  allow(my_bar).to receive :save!
  allow(my_bar).to receive :new_record?

  expect(my_bar).to receive :send_notifications

  allow(my_bar).to receive(:to_model).and_return my_bar
  allow(my_bar).to receive(:persisted?).and_return true
  allow(my_bar).to receive(:model_name).and_return ActiveModel::Name.new MyBar

  post my_bars_path, params: { my_bar: valid_attributes }
end

超出expectallow在那里模拟@my_bar = @my_foo.my_bars.create! my_bar_params @my_bar = @my_foo.my_bars.create! my_bar_params expect下的allow的 rest 在那里模拟redirect_to @my_bar

我不知道这是否是 RSpec 的创建者希望我们编写的,但它似乎不太符合人体工程学。

所以,我的问题是:有没有其他方法可以编写这样的测试,它不涉及 mocking 大量 AR 内部构件,也不需要我更改 controller 操作中的代码?

我喜欢 [using expect_any_instance_of] 因为它简洁明了,但 RSpec 的创建者似乎弃用了它。

他们有充分的理由阻止它。 如果 controller 代码发生变化并且其他东西调用了 send_notifications 怎么办? 测试会通过。

必须使用 expect_any_instance_of 或 allow_any_instance_of 表明代码做得太多,可以重新设计。


有什么不能通过添加另一个抽象层来解决的?

def create
  my_bar.send_notifications

  redirect_to my_bar
end

private

def my_foo
  MyFoo.find params[:foo_id]
end

def my_bar
  my_foo.my_bars.create! my_bar_params
end

现在您可以模拟my_bar以返回一个双精度值。 如果该方法更广泛地使用了 my_bar,您还可以返回一个真正的 MyBar。

it 'sends the notifications' do
  bar = instance_double(MyBar)
  allow(@controller).to receive(:my_bar).and_return(bar)

  post my_bars_path, params: { my_bar: valid_attributes }
end

在 controller 中封装查找和创建模型和记录是一种常见模式。

它还在测试和方法之间创建了一种令人愉悦的对称性,表明该方法正在做它必须做的事情,而不是更多。


或者,使用服务 object 来处理通知并进行检查。

class MyNotifier
  def self.send(message)
    ...
  end
end

class MyBar
  NOTIFICATION_MESSAGE = "A bar, a bar, dancing in the night".freeze

  def send_notification
    MyNotifier.send(NOTIFICATION_MESSAGE)
  end
end

然后测试通知是否发生。

it 'sends the notifications' do
  expect(MyNotifier).to receive(:send)
    .with(MyBar.const_get(:NOTIFICATION_MESSAGE))

  post my_bars_path, params: { my_bar: valid_attributes }
end

通过send class 方法,我们不需要使用expect_any_instance_of 出于这个原因和许多其他原因,将服务对象编写为没有 state 的类 singleton 是一种常见模式。

这里的缺点是它确实需要了解 MyBar#send_notification 是如何工作的,但如果应用程序使用相同的服务 object 进行通知,这是可以接受的。


或者,创建一个 MyFoo 供其查找。 模拟其创建 MyBar 的调用,确保检查 arguments 是否正确。

let(:foo) {
  MyFoo.create!(...)
}
let(:foo_id) { foo.id }

it 'sends the notifications' do
  bar = instance_double(MyBar)
  expect(bar).to receive(:send_notifications).with(no_args)

  allow(foo).to receive_message_chain(:my_bars, :create!)
    .with(valid_attributes)
    .and_return(bar)

  post my_bars_path, params: { foo_id: foo_id, my_bar: valid_attributes }
end

这需要更多的内部知识,并且rubocop-rspec 不喜欢消息链


或者,模拟MyFoo.find以返回 MyFoo double。 同样,确保只接受正确的 arguments。

it 'sends the notifications' do
  foo = instance_double(MyFoo)
  allow(foo).to receive_message_chain(:my_bars, :create!)
    .with(valid_attributes)
    .and_return(bar)

  bar = instance_double(MyBar)
  expect(bar).to receive(:send_notifications).with(no_args)

  allow(MyFoo).to receive(:find).with(foo.id).and_return(foo)

  post my_bars_path, params: { foo_id: foo_id, my_bar: valid_attributes }
end

或者你可以allow(MyFoo).to receive(:find).with(foo_id).and_return(mocked_foo) ,但我发现最好尽可能少地模拟。

在这种情况下,我要做的是使 controller 变“哑”,将持久性和通知逻辑移到服务中,并在集成而不是存根中测试服务。

# controller
def create
  my_bar = CreateBar.call params[:foo_id], my_bar_params

  redirect_to my_bars_path(my_bar) 
end

# service
class CreateBar
  def self.call(foo_id, bar_params)
    foo = MyFoo.find params[:foo_id]

    bar = foo.my_bars.create! bar_params
    bar.send_notifications

    bar
  end
end

# controller spec
it 'creates a new bar' do
  bar = double :bar, id: 'whatever'

  expect(CreateBar).to receive(:call).with(<attributes>).and_return(bar)

  post my_bars_path, params: {my_bar: valid_attribute}
end

it 'redirects to the created bar' do
  bar = double :bar, id: 1

  allow(CreateBar).to receive(:call).with(<attributes>).and_return(bar)

  post my_bars_path, params: {my_bar: valid_attribute}

  expect(response).to redirect_to(my_bars_path(1))
end

# service spec
it 'creates a bar' do
  # Or just create the object via AR's interface if you don't use factory bot.
  foo = FactoryBot.create :foo

  CreateBar.call foo.id, <bar_params>

  bars = Foo.find(foo.id).bars

  expect(bars.count).to eq 1
  expect(bars.first).to have_attribute <expected_attributes>
end

it 'sends a bar notification' do
  foo = FactoryBot.create :foo

  CreateBar.call foo.id, <bar_params>

  # NotificationCenter is expected to be called from #send_notification
  # and in test environment it records your notifications instead of actually
  # sending them.
  notifications = NotificationCenter.notifications

  expect(notifications.count).to eq <expected notifications count>
  # + assert that the notifications have the properties based on the <bar_params>
end

根据 if #send_notifications的复杂性,您可以将其内联到CreateFoo中(从而使持久性 model 与业务逻辑分离)或拥有单独的服务SendFooNotifications

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM