The ‘no problemo’ basic guide to doubles and stubs in RSpec

Ed Packard
6 min readAug 5, 2021
Photo by Rock'n Roll Monkey on Unsplash

Last week I had to use doubles and stubs in RSpec for the first time, and — I’ll be honest — most of the documentation and guidance I found online was not well-suited to a beginner. In this blog, I’ll record some of what I’ve learned about using doubles and stubs and hopefully it will be useful for others who are looking to include them in their tests.

First up: why did I need to use doubles and stubs? Well, when unit testing an object in RSpec, you typically want to avoid as much interference from other objects as possible. Let’s say you were testing an Airport object with Plane objects. What if bugs emerged in the code for the Plane objects? It would break your unit tests for the Airport. Because your Airport presumably needs planes, you can substitute them with doubles in your RSpec tests. What if you needed to simulate the return values of methods called on objects from other classes? You can do this with stubs. The following mini-project shows how both these concepts work, and why they’re good practice in testing.

Setting up

Set up a new folder, and initialise it with RSpec ( rspec --init). This will automatically create a specsubdirectory. Now make a lib subdirectory, so you end up with something like this:

New_Project
|
--lib
|
--spec

For this project, we’re going to imagine we’re running some kind of rogue robotics/artificial intelligence operation and our factory is represented as an object of the following class:

# this file is saved as './lib/skynet.rb'require_relative 'terminator'class Skynet  def inspection_one(terminator)
terminator
end
def inspection_two(terminator)
terminator.kill_test
terminator.learn_test
end
end

So it seems our factory’s main task is to inspect terminators. The two inspection methods possibly arose from unit tests like these:

# this file is saved as './spec/skynet_spec.rb'require 'skynet'describe Skynet do  it "inspects terminators" do
terminator = Terminator.new
expect(subject.inspection_one(terminator)).to eq terminator
end
it "inspects terminators and tests modes are set correctly" do
terminator = Terminator.new
message = "Inspection passed"
expect(subject.inspection_two(terminator)).to eq message
end
end

Testing like this is fraught with peril: what do we know about the Terminator class? For starters, it might not work properly, in which case our test would fail for reasons that have nothing to do with the quality of the Skynet code. In fact, it will fail at this stage because we haven’t defined a Terminator class yet. Let’s go ahead and create a terminator:

# this file is saved as './lib/terminator.rb'class Terminator  def initialize
@kill_mode = true
@learn_mode = false
end
def kill_test
raise "Hasta la vista baby!" if @kill_mode == true
end
def learn_test
return "Inspection passed" if @learn_mode == false
end
end

I’m guessing whoever designed the Terminator class did not follow recommended TDD practice, or indeed any sort of ethical guidelines. In any case, it is clear that the terminators we create have two modes: first, a kill mode which, if set to true, will provoke a lethal response (aka raise an error). Second, Skynet doesn’t want its terminators to learn when sent back in time on missions, so the learn test only passes when the ‘switch’ is turned off (set to false).

Doubles

It is fairly obvious what will happen if we try to run our Skynet tests with an actual terminator: the first test will pass, albeit because it’s not actually a very good test — it’s basically returning whatever is passed into the method. The inspection_two method is more rigorous as it actually inspects the terminator’s variables and only passes the inspection if both are set as false. Somebody has set the factory default to ‘kill’, so any attempt to run, or test, this method with an actual Terminator object will inevitably fail. We could set the kill mode to false in terminator.rb, but then we’re only saving up a problem for the future: our Skynet tests might pass today, but what if somebody changes the Terminator class in future? That’s where doubles come in: a double will stand in the place of actual terminators and present no danger to us, or the Skynet unit tests:

# this file is saved as './spec/skynet_spec.rb'require 'skynet'describe Skynet do  it "inspects terminators" do
terminator = double
expect(subject.inspection_one(terminator)).to eq terminator
end
it "inspects terminators and tests modes are set correctly" do
terminator = double
message = "Inspection passed"
expect(subject.inspection_two(terminator)).to eq message
end
end

Not much has changed — just note that in both tests terminator = Terminator.new is now terminator = double. To reiterate: a double is basically an empty object that can represent an instance of a class — it doesn’t actually instantiate an object from a class. So the first test still passes in RSpec, although, to be fair, we could pass pretty much anything into that method and it will pass, as it simply returns the parameter passed to the method. But it makes the point clearly enough: if you need an object from a different class (like a car, plane, or terminator) that doesn’t need to do anything else in the test except be there, a simple double can be used. In any case, we have now totally isolated our Skynet tests from the Terminator class.

Stubs

RSpec will still fail the second unit test. Thankfully, the error message is no longer “Hasta la vista baby!” but the more mundane #<Double (anonymous)> received unexpected message :kill_test with (no args). This tells us that the terminator double does not understand the Terminator class method kill_test — remember: a double knows nothing about the class it is representing! However, we can fix this with stubs (of course, the double won’t understand the learn_test method either, so that will also need to be stubbed).

You will see in the paragraph above that a double object, by default, looks like this: #<Double (anonymous)>. You can change the anonymous part like this: terminator = double('Arnold'). If you’re using lots of different doubles, this can be helpful in order to keep track of them.

A stub basically tells a double how to respond to a method call — they do not replicate methods, but can provide return values. The handy thing about them is that you set the return value, and can therefore avoid unexpected or random behaviour (i.e. if you were testing an Airport class which got its weather from a Weather class, you could ensure that all your Airport tests involved perfect weather by implementing a weather double and stub).

For the terminators, here’s what we need to do in our spec file:

# this file is saved as './spec/skynet_spec.rb'require 'skynet'describe Skynet doit "inspects terminators" do
terminator = double
expect(subject.inspection_one(terminator)).to eq terminator
end
it "inspects terminators and tests modes are set correctly" do
terminator = double
message = "Inspection passed"
allow(terminator).to receive(:kill_test).and_return(false)
allow(terminator).to receive(:learn_test).and_return(message)
expect(subject.inspection_two(terminator)).to eq message
end
end

Hopefully the syntax is clear — we are allowing our terminator doubles to receive the methods kill_test and learn_test (note these methods are written as symbols in the test). Now when inspection_two runs with our terminator double, it calls the kill_test and returns false, as we specified, and then calls the learn_test and returns the message that passes the test. RSpec should go green and nobody is getting terminated.

The key thing is to bear in mind is that you are not testing the methods that exist outside the class you are testing (i.e. Terminator in this example). You are ‘stubbing’ them so that you can test that the methods of a specific class (i.e. the inspections methods in Skynet) work in isolation. Admittedly, the methods in skynet.rb are a little basic and silly, but they have shed some light on how doubles and stubbing work.

You might also want to declare your doubles with let at the start of your tests, so you don’t have to repeat them for every test, and you might want to refactor your allow syntax too — the following code performs exactly the same tests as the previous example, but note the slight differences:

# this file is saved as './spec/skynet_spec.rb'require 'skynet'describe Skynet do  let (:terminator) { double }  it "inspects terminators" do
expect(subject.inspection_one(terminator)).to eq terminator
end
it "inspects terminators and tests modes are set correctly" do
message = "Inspection passed"
allow(terminator).to receive(:kill_test) { false }
allow(terminator).to receive(:learn_test) { message }
expect(subject.inspection_two(terminator)).to eq message
end
end

There is of course a whole lot more you can do with methods and stubs, so once you’ve got a hang of these basics, it is worth going back to the RSpec mocks documentation and playing around. Meanwhile, as ever, I hope this has been useful (or at least interesting) and thanks for reading!

--

--