Replacing Mocks With Hand-Written Test Doubles
Consider the following test.
RSpec.describe Commands::StartRecording do
it "spawns one recording process per camera" do
expect(ProcessManager).to receive(:spawn).with(/camera_11/).and_return(123)
expect(ProcessManager).to receive(:spawn).with(/camera_22/).and_return(456)
allow(ProcessManager).to receive(:exists?).with(123).and_return(true)
allow(ProcessManager).to receive(:exists?).with(456).and_return(true)
result = Commands::StartRecording.(cameras: [11, 22])
expect(result).to be_successful
end
# (more tests here)
end
I will be using RSpec in this article, but the concept applies equally well to Minitest.
This test checks that Commands::StartRecording
spawns multiple processes via ProcessManager
.
We don’t want to actually spawn any processes during testing, so methods on ProcessManager
have been mocked out using RSpec Mocks.
There are a few problems with this test.
Problem: Arrange-Act-Assert
People generally expect tests to follow the arrange-act-assert pattern. For example:
# Step 1: Arrange
parrot = Parrot.new
# Step 2: Act
noise = parrot.squawk
# Step 3: Assert
expect(noise).to be_loud
The Commands::StartRecording
test does not follow this structure.
This is a common problem with tests that include mocking.
# Step 1: Assert and arrange
expect(ProcessManager).to receive(:spawn).with(/camera_11/).and_return(123)
expect(ProcessManager).to receive(:spawn).with(/camera_22/).and_return(456)
# Step 2: More arranging
allow(ProcessManager).to receive(:exists?).with(123).and_return(true)
allow(ProcessManager).to receive(:exists?).with(456).and_return(true)
# Step 3: Act
result = Commands::StartRecording.(cameras: [11, 22])
# Step 4: More assertions
expect(result).to be_successful
expect(result.value).to be_a(Session)
This awkward structure is necessary because ProcessManager
needs to be mocked and stubbed before the “act” step.
Problem: Coupling
Let’s say we want to rename the ProcessManager.spawn
method to ProcessManager.run
.
Now we have to go back and change every single test that mocked out the spawn
method.
To say it another way, the tests are fragile.
This is what happens when code is tightly coupled. Changing one piece of code causes other pieces to break, and each breakage must be fixed individually.
Any interface change will require the corresponding mocked/stubbed methods to be updated too. This includes:
- renaming methods
- adding, removing, or changing arguments
- changing return values
Problem: Reusability
Let’s say we want to test something else that also relies upon ProcessManager
.
That would involve doing the mocks all over again.
RSpec.describe Commands::RecordTelevision do
it "records a TV channel" do
# this is basically copy/pasted from the other test
expect(ProcessManager).to receive(:spawn).with(/nickelodeon/).and_return(123)
allow(ProcessManager).to receive(:exists?).with(123).and_return(true)
result = Commands::RecordTelevision.(channel: 'Nickelodeon')
expect(result).to be_successful
end
# (more tests here)
end
Technically we could reuse a mocked methods with RSpec shared contexts, but that’s probably a bad idea.
Mocks don’t get reused — they get duplicated.
This just creates more fragile tests that are tightly coupled to ProcessManager
.
Solution: Hand-Written Doubles
Wouldn’t it be nice if there was a way to:
- decouple all the tests from the
ProcessManager
interface, - in a reusable way,
- that also makes the tests read more nicely?
Well here is the test, refactored to use ProcessManagerDouble
, a hand-written class that replaces ProcessManager
:
RSpec.describe Commands::StartRecording do
before { stub_const('ProcessManager', process_manager) }
let(:process_manager) { ProcessManagerDouble.new }
it "starts a recording process per camera" do
result = Commands::StartRecording.(cameras: [11, 22])
expect(result).to be_successful
expect(process_manager).to have_spawned(/camera_11/)
expect(process_manager).to have_spawned(/camera_22/)
end
# (more tests here)
end
This is Gary-Bernhardt-style constant stubbing, instead of dependency injection, but that’s a topic for a different article.
The real ProcessManager
object has been replaced with a fake ProcessManagerDouble
object.
Instead of using the mocking features of RSpec, the new ProcessManagerDouble
class is custom-made, and does not depend upon RSpec.
We will get to the implementation later in this article.
Fixed: Arrange-Act-Assert
With the mocking removed, the test fits the arrange-act-assert pattern better.
# Step 1: Arrange
before { stub_const('ProcessManager', process_manager) }
let(:process_manager) { ProcessManagerDouble.new }
it "spawns one recording process per camera" do
# Step 2: Act
result = Commands::StartRecording.(cameras: [11, 22])
# Step 3: Assert
expect(result).to be_successful
expect(process_manager).to have_spawned(/camera_11/)
expect(process_manager).to have_spawned(/camera_22/)
end
Fixed: Coupling
Notice how the spawn
method is never mentioned within the test.
That is because the test is now decoupled from the ProcessManager
interface.
If we change the ProcessManager
interface, the tests still need to be updated too.
But instead of fixing every test individually, it only needs to be fixed in a single place: the ProcessManagerDouble
class.
This means that the tests are less fragile, and ProcessManager
is easier to refactor.
Fixed: Reusability
PORO = Plain Old Ruby Object.
The ProcessManagerDouble
can be reused from any test.
It’s just a PORO with no dependencies, so it can be used anywhere.
The Implementation
class ProcessManagerDouble
def initialize
@next_pid = 1337
@processes = {}
end
def spawn(cmdline)
pid = @next_pid
@next_pid += 1
@processes[pid] = cmdline
pid
end
def exists?(pid)
@processes.key?(pid)
end
def has_spawned?(matcher)
@processes.values.any? { |cmdline| matcher === cmdline }
end
end
The spawn
and exists?
methods are replacements for the same methods on ProcessManager
.
They just keep a track of the “running” processes, using a hash.
The has_spawned?
method is unique to ProcessManagerDouble
.
It is designed to be used in the assertions of tests.
If this was Minitest, it could be used like this:
assert process_manager.has_spawned?(/whatever/)
In RSpec, since we’ve followed the proper naming convention, it can be used like this:
expect(process_manager).to have_spawned?(/whatever/)
You can put these classes directly into the same file as your tests.
If the class is shared across multiple test files, I put them in the spec/support/
directory.
Advanced Features
Having a hand-written test double class allows you to test more-complicated scenarios, without cluttering up the tests.
For example, if we wanted to test when one process terminates unexpectedly, it could be written like this:
it "fails if any process terminates unexpectedly" do
process_manager.fail_when_spawning!(/camera_22/)
result = Commands::StartRecording.(cameras: [11, 22])
expect(result).to be_failure
expect(result.error).to eq('Failed to spawn camera 22')
end
All the complexity is hidden inside ProcessManagerDouble
.
The test only requires one additional, descriptive line of code.
This communicates the intent of the test more clearly than a complicated series of mocked methods.
When To Use Hand-Written Doubles
For simple interfaces, it’s OK to use a double
, or even better: a spy
.
it "sends an email" do
mailer = spy
command = Commands::StartRecording.new(mailer: mailer)
command.call(cameras: [11, 22])
expect(mailer).to have_received(:recording_started)
end
But as soon as the mocking code grows beyond one or two lines, consider making a hand-written double.
If the class could be reused across other tests, that is another good reason to write one.
Got questions? Comments? Milk?
Shoot an email to [email protected] or hit me up on Twitter (@tom_dalling).