如何在不使用 rspec 将测试耦合到被测代码的情况下 stub/mock?
How to stub/mock without coupling the test to the code under test with rspec?
假设有一个工人,他的工作是:
- 通过一组critaria查找或创建一条记录;
- 更新记录的属性。
这是一个示例实现:
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
收到意外消息 baz
和 dam
的错误。
现在我需要 expect/allow
这些消息或使用
Null object double.
所以在这一切之后,我完成了一个测试,它非常紧密地反映了实现
正在接受测试的 methods/classes。我为什么要关心,一些额外的
在获取记录时通过 includes
预先加载记录?我为什么要在乎,
那在调用update
之前,我还调用了一些方法(baz
,dam
)记录在案?
这是 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
假设有一个工人,他的工作是:
- 通过一组critaria查找或创建一条记录;
- 更新记录的属性。
这是一个示例实现:
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
收到意外消息 baz
和 dam
的错误。
现在我需要 expect/allow
这些消息或使用
Null object double.
所以在这一切之后,我完成了一个测试,它非常紧密地反映了实现
正在接受测试的 methods/classes。我为什么要关心,一些额外的
在获取记录时通过 includes
预先加载记录?我为什么要在乎,
那在调用update
之前,我还调用了一些方法(baz
,dam
)记录在案?
这是 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