RSpec Tips: allow_any_instance_of
Any system under test needs some degree of mocks. Boundaries need to be drawn in order to accentuate each layer of your testing pyramid. By mocking out side effects we are able to test the interface of a single unit (method/worker/service etc.) and ensure that many edge cases can run quickly and consistently.
If you are an RSpec user the way to accomplish these is to use allow
in combination with an instance_double
or class_double
.
cool_class.rb
class CoolClass
def self.foo
'bar'
end
end
cool_class_spec.rb
require 'rspec'
RSpec.describe CoolClass do
let(:mocked_cool_class) { class_double(CoolClass) }
it 'returns cool! because we kindly asked it to' do
allow(mocked_cool_class).to receive(:foo).and_return('cool!')
expect(mocked_cool_class.foo).to eq('cool!')
end
end
Verifying doubles can be tedious to setup in cases where you don’t have a clear picture of how your unit under test is using your mocked class. And what if we don’t care? What if the mocked class is a side effect of which you have no concern about? It is still useful to make sure you are calling the side effect, but can we assure it is being called with minimal work?
In this case, you might be tempted to reach for any_instance_of
which appropriately mocks out any instance of the class.
Let’s say someone has a need for a side effect to execute within your class.
contrived_service.rb
class ContrivedService
def some_side_effect
# ... some complicated business process
end
end
cool_class.rb
require 'contrived_service'class CoolClass
def cool_method
ContrivedService.new.some_side_effect
'Hiya buddy!'
end
def self.foo
'bar'
end
end
But all we care about is that cool_method
returns ‘Hiya buddy!’ and we don’t have a clear picture of what some_side_effect
does.
Let’s just mock any instance of ContrivedService! 😍
cool_class_spec.rb
require 'rspec'
RSpec.describe CoolClass do
# ...
describe '#cool_method' do
before do
allow_any_instance_of(ContrivedService)
.to receive(:some_side_effect)
end
it 'returns hiya buddy and does something else that we are not worried about right meow' do
expect_any_instance_of(ContrivedService)
.to receive(:some_side_effect)
expect(described_class.new.cool_method).to eq('Hiya buddy!')
end
end
end
Boom! Tests are passing, we are accurately testing the interface of our cool_method
and it’s time for a coffee break!
Some time passes, and a stakeholder realizes we should’ve been calling our side effect multiple times in certain scenarios! Ok no problem we know exactly how to do that!
def cool_method(cool_argument = 'no')
ContrivedService.new.some_side_effect
ContrivedService.new.some_side_effect if cool_argument == 'yes'
'Hiya buddy!'
end...context 'when passed in yes' do
it 'runs the side effect twice' do
expect_any_instance_of(ContrivedService)
.to receive(:some_side_effect).twice
expect(described_class.new.cool_method('yes')).to eq('Hiya buddy!')
end
end
end
But wait, our tests fail, RSpec says:
RSpec::Mocks::MockExpectationError: The message 'some_side_effect' was received by #<ContrivedService:103980 > but has already been received by #<ContrivedService:0x00007fbf2cb887e0>
any_instance_of
with a numerical qualifier asserts that any one instance of that class received the method n
number of times. Additionally, our double is scoped to one instance and one instance only. This means additional instances of ContrivedService
have undefined behavior so RSpec appropriately raises an error.
In this case, we should reach for more granular doubles instead, with a bit more work we can make our tests accurately reflect what should be happening.
describe '#cool_method' do
let(:instance) { instance_double(ContrivedService) }
let(:double_class) {
class_double(ContrivedService).as_stubbed_const
}
before do
allow(double_class).to receive(:new).and_return(instance)
allow(instance).to receive(:some_side_effect)
end
it 'returns hiya buddy and does something else that we are not worried about right meow' do
expect(described_class.new.cool_method).to eq('Hiya buddy!')
expect(instance).to have_received(:some_side_effect)
end
context 'when passed in yes' do
it 'runs the side effect twice' do
expect(described_class.new.cool_method('yes')).to eq('Hiya buddy!')
expect(instance).to have_received(:some_side_effect).twice
end
end
end
Now we are shoveling all new instances of our service through the same double, so RSpec gives us the ✅ .
This additional granularity allows future devs to change what the service class does without fear of impacting other tests. Let’s say our side effect needs some safeguarding because it can throw a NoMethodError
in some cases.
def cool_method(cool_argument = 'no')
begin
ContrivedService.new.some_side_effect
ContrivedService.new.some_side_effect if cool_argument == 'yes'
'Hiya buddy!'
rescue NoMethodError
'Sorry buddy something happened!'
end
end....context 'when the side effect blows up' do
before do
allow(instance).to receive(:some_side_effect).and_raise(NoMethodError)
end
it 'returns an appropriate response' do
expect(described_class.new.cool_method).to eq('Sorry buddy something happened!')
end
end
Any system under test inevitably needs to handle tradeoffs.
Although we’d like to say we never use any_instance_of
there are some cases where it may make sense!
It’s important to understand the pros and cons of our tools so that we are appropriately suited to update them in the future.