How to Write Future-proof Mocks in RSpec 3
22 Nov 2015Tests are an important component in most software applications. Whether tests drive the development, or are strapped on after the fact, tests need to be reliable for future development to progress. An application’s complexity rises with time. During and after this increase, a rock solid test suite is crucial.
Let the Dogs Out
Given an application that deals with Dogs
:
class Dog
end
Keeping track of grooming statistics about this application’s dogs is the obvious cash cow. But that sounds very tedious and complicated, so we can assume that a GroomingService
exists that exposes a single Groomer
object:
module GroomingService
class Groomer
def initialize(dog)
@dog = dog
end
def groom
# Some complex grooming logic
end
end
end
This service was included by the groomer
gem and will become a core piece of the application. To keep everyone in the project sane and well rested, a robust spec suite is needed.
Spec It Up
Using RSpec
and the GroomingService
, a very basic test can be written to make sure the code works as expected.
Introducing the GroomingService
into the Dog
class could look like:
class Dog
def groom
GroomingService::Groomer.new(self).groom
end
end
The first test should be to make sure that when the groom
method on a Dog
is called, it initializes and calls groom
out to the GroomService::Groomer
object.
When asserting that a certain set of methods is called on a particular object, the built in RSpec double method is very handy.
The double
method returns a stand-in object to assert allow
and expect
messages against. Any method that is called on the test double which is not explicitly allow
ed or expect
ed results in an error.
describe Dog do
subject { described_class.new }
describe '#groom' do
let(:groomer) { double(GroomingService::Groomer) }
it 'initializes and calls groom' do
expect(GroomingService::Groomer).to receive(:new) { groomer }
expect(groomer).to receive(:groom)
subject.groom
end
end
end
As expected, this test passes beautifully:
$: rspec dog_spec.rb
Dog
#groom
calls new with self and then groom
Finished in 0.00773 seconds (files took 0.09367 seconds to load)
1 example, 0 failures
That can be it, right? Pack it up and put the dogs away, whoever let them out will have to do it all again later.
Think of the Future
After some time passes, the GroomingService::Groomer
has changed its method signature. Instead of the groom
method, it has changed to groom!
. Chances are someone thought that either the method had too many side affects or that !
are just really awesome looking.
In any case, the included service changes and when the test runs:
$: rspec dog_spec.rb
Dog
#groom
calls new with self and then groom
Finished in 0.00735 seconds (files took 0.10532 seconds to load)
1 example, 0 failures
It passes? How can that be? The method changed from groom
to groom!
. The groom
method does not even exist on GroomingService::Groomer
. It seems that the specs do not care what does or does not exist on the double
, they just happily pass.
Use instance_double
The answer here is to use instance_double
. Like double
, instance_double
also returns a stand-in object that raises errors if methods not allow
ed or expect
ed are called on the object.
The key difference is that instance_double
checks the underlying object to make sure that it responds to methods before making assertions that they are called.
describe Dog do
subject { described_class.new }
describe '#groom' do
let(:groomer) { instance_double(GroomingService::Groomer) }
it 'initializes and calls groom' do
expect(GroomingService::Groomer).to receive(:new) { groomer }
expect(groomer).to receive(:groom)
subject.groom
end
end
end
Now running this test, we see the failure we should expect:
$: rspec dog_spec.rb
Dog
#groom
calls new with self and then groom (FAILED - 1)
Failures:
1) Dog#groom calls new with self and then groom
Failure/Error: expect(groomer).to receive(:groom)
the GroomingService::Groomer class does not
implement the instance method: groom
Finished in 0.0054 seconds (files took 0.08288 seconds to load)
1 example, 1 failure
While no one likes failing specs, a failure here is always preferable to one in production.
Since the API that we do not own (Groomer
) changed, it was a mistake to blindly mock it without asking the underlying object if that method existed. However, with this problem corrected, the code is more robust and development can continue.
Mocking with Confidence
Asserting that a method is called on an object is a very common pattern in Ruby tests. Additionally, Ruby does not have the benefit of compiled languages in terms of method invocation validation. We as Rubyists rely on automated testing to make sure that our applications work as expected, can be iterated upon, and handle third party library updates. While it may be attractive to use permissive mocking assertions, they can be dangerous and cause more problems than they are worth.