如何在不使用 rspec 将测试耦合到被测代码的情况下 stub/mock?

How to stub/mock without coupling the test to the code under test with rspec?

假设有一个工人,他的工作是:

这是一个示例实现:

class HardWorker
  include SidekiqWorker

  def perform(foo_id, bar_id)
    record = find_or_create(foo_id, bar_id)

    update_record(record)
  end

  private

  def find_or_create(foo_id, bar_id)
    MyRecord.find_or_create_by(foo_id: foo_id, bar_id: bar_id)
  end

  def update_record(record)
    result_of_complicated_calculations = ComplicatedService.new(record).call

    record.update(attribute: result_of_complicated_calculations)
  end
end

我想测试一下:

测试最后一个的一种方法是使用expect_any_instance_of

expect_any_instance_of(MyRecord).to receive(:update)

问题是expect/allow_any_instance_of的用法是discouraged:

The rspec-mocks API is designed for individual object instances, but this feature operates on entire classes of objects. As a result there are some semantically confusing edge cases. For example in expect_any_instance_of(Widget).to receive(:name).twice it isn't clear whether each specific instance is expected to receive name twice, or if two receives total are expected. (It's the former.)

Using this feature is often a design smell. It may be that your test is trying to do too much or that the object under test is too complex.

It is the most complicated feature of rspec-mocks, and has historically received the most bug reports. (None of the core team actively use it, which doesn't help.)

正确的方法是使用 instance_double。所以我会尝试:

record = instance_double('record')

expect(MyRecord).to receive(:find_or_create_by).and_return(record)

expect(record).to receive(:update!)

一切都很好,但是,如果我有这个实现会怎样:

MyRecord.includes(:foo, :bar).find_or_create_by(foo_id: foo_id, bar_id: bar_id)

现在,expect(MyRecord).to receive(:find_or_create_by).and_return(record),行不通了,因为 实际上接收 find_or_create_by 的对象是 MyRecord::ActiveRecord_Relation.

所以现在我需要存根调用 includes:

record = instance_double('record')

relation = instance_double('acitve_record_relation')

expect(MyRecord).to receive(:includes).and_return(relation)

expect(relation).to receive(:find_or_create_by).and_return(record)

此外,假设我这样调用我的服务:

ComplicatedService.new(record.baz, record.dam).call

现在,我会收到 record 收到意外消息 bazdam 的错误。 现在我需要 expect/allow 这些消息或使用 Null object double.

所以在这一切之后,我完成了一个测试,它非常紧密地反映了实现 正在接受测试的 methods/classes。我为什么要关心,一些额外的 在获取记录时通过 includes 预先加载记录?我为什么要在乎, 那在调用update之前,我还调用了一些方法(bazdam)记录在案?

这是 rspec-mocks 框架的限制/框架的哲学还是我 我用错了吗?

我对初始版本进行了一些修改以使其更易于测试:

class HardWorker
  include SidekiqWorker

  def perform(foo_id, bar_id)
    record = find_or_create(foo_id, bar_id)

    update_record(record)
  end

  private

  def find_or_create(foo_id, bar_id)
    MyRecord.find_or_create_by(foo_id: foo_id, bar_id: bar_id)
  end

  def update_record(record)
    # change for easier stubbing
    result_of_complicated_calculations = ComplicatedService.call(record)

    record.update(attribute: result_of_complicated_calculations)
  end
end

我要测试的方式是:

describe HardWorker do
  before do
    # stub once and return an "unique value"
    allow(ComplicatedService).to receive(:call).with(instance_of(HardWorker)).and_return :result_from_service
  end

  # then do two simple tests
  it 'creates new record when one does not exists' do
    allow(ComplicatedService).to receive(:call).with(instance_of(HardWorker)).and_return :result_from_service
    HardWorker.call(1, 2)

    record = MyRecord.find(foo_id: 1, bar_id: 2)

    expect(record.attribute).to eq :result_from_service
  end

  it 'updates existing record when one exists' do
    record = create foo_id: 1, bar_id: 2

    HardWorker.call(record.foo_id, record.bar_id)

    record.reload

    expect(record.attribute).to eq :result_from_service
  end
end