Replacing Mocks With Hand-Written Test Doubles

??? words · ??? min read

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).

← Previously: Super Secret Methods

Next up: Testing Podcast Feed →

Join The Pigeonhole

Don't miss the next post! Subscribe to Ruby Pigeon mailing list and get the next post sent straight to your inbox.