Migrating from RSpec Dynamic Mocks to Surrogate - Part 2

Migrating from RSpec Dynamic Mocks to Surrogate - Part 2

Eric Meyer
Eric Meyer

January 13, 2013

This blog is Part 2 of a series of blogs about migrating to Surrogate. Part 1 can be found here. If you are unfamiliar with Surrogate and haven't read Part 1, I recommend reading it before this blog.

To recap, Part 1 contained a introduction to both the problem and to Surrogate, along with an example of migrating an RSpec mock used for stubbing attributes.

Part 2 will contain more complicated examples including how to migrate mocks using message expectations and how to mock out methods that affect the return value of a different method. We will continue using Conway's Game of Life for our examples.

Let's jump right into some code!

Migrating Message Expectations With Expected Arguments

The example we are going to migrate over is one with message expectations with arguments. In other words, tests using should_receive. The section of code we are looking at is the main game loop for Conway's Game of Life.

The runner takes an instance of the Board and an outputter, both of which are going to be represented using mocks. As long as there are living cells on the board, the outputter shows the board, and then the board advances to the next generation.

The Specs Using should_receive

module GameOfLife
 describe Runner do

 before(:each) do
 @outputter = mock('ConsoleOutputter', show_board: nil)
 @board = mock('board', has_any_living_cells?: true, advance_generation: nil)
 @runner = Runner.new(@board, @outputter)
 @runner.stub(:sleep)
 end

 it "outputs the board" do
 @board.stub(:has_any_living_cells?).and_return(true, false)
 @outputter.should_receive(:show_board).with(@board)
 @runner.run
 end

 it "advances the board's generation" do
 @board.stub(:has_any_living_cells?).and_return(true, false)
 @board.should_receive(:advance_generation)
 @runner.run
 end

 it "pauses between loops" do
 @board.stub(:has_any_living_cells?).and_return(true, false)
 @runner.should_receive(:sleep).with(Runner::TIME_BETWEEN_GENERATIONS)
 @runner.run
 end

 it "shows the board until there are no living cells" do
 @board.stub(:has_any_living_cells?).and_return(true, true, false)
 @outputter.should_receive(:show_board).exactly(2).times
 @runner.run
 end

 end
end

Adding a Surrogate Console Outputter

Like last time, we first need to create the surrogate and define the methods we expect it to have.

require "surrogate/rspec"

class MockOutputter
 Surrogate.endow(self)
 define(:show_board) { |board| }
end

To convert the test that asserts that show_board was called, you simply move the assertion to after @runner.run and change the matcher from should_receive to should have_been_told_to. Here it is also easy to migrate tests over one at a time, since should_receive still works on surrogate objects, even though that defeats the purpose of using Surrogate. Message expectations set up using should_receive will not ensure that the method exists.

module GameOfLife
 describe Runner do

 before(:each) do
 @outputter = MockOutputter.factory
 @board = mock('board', has_any_living_cells?: true, advance_generation: nil)
 @runner = Runner.new(@board, @outputter)
 @runner.stub(:sleep)
 end

 it "outputs the board" do
 @board.stub(:has_any_living_cells?).and_return(true, false)
 @runner.run
 @outputter.should have_been_told_to(:show_board).with(@board)
 end

 it "advances the board's generation" do
 @board.stub(:has_any_living_cells?).and_return(true, false)
 @board.should_receive(:advance_generation)
 @runner.run
 end

 # Additional Tests Omitted...
 end
end

One nice thing to notice is that we don't have to explicitly stub show_board for the tests that don't care about it. This is because we set the default behavior of show_board on MockOutputter by passing in a block when we called define. Passing in the block is optional, and passing no block when calling define is the same as passing in an empty block yielding nothing.

What happens when I set an expectation on an undefined method?

module GameOfLife
 describe Runner do

 before(:each) do
 @outputter = MockOutputter.factory
 @board = mock('board', has_any_living_cells?: true, advance_generation: nil)
 @runner = Runner.new(@board, @outputter)
 @runner.stub(:sleep)
 end

 it "outputs the board" do
 @board.stub(:has_any_living_cells?).and_return(true, false)
 @runner.run
 @outputter.should have_been_told_to(:show_THE_board).with(@board)
 end

 # Additional Tests Omitted...
 end
end
Failures:

 1) GameOfLife::Runner outputs the board
 Failure/Error: @outputter.should have_been_told_to(:show_THE_board).with(@board)
 Surrogate::UnknownMethod:
 doesn't know "show_THE_board", only knows "show_board"
 # ./spec/game_of_life/runner_spec.rb:17:in `block (2 levels) in <module:GameOfLife>'

Taking Advantage of Hand-Rolled Mocks

Converting the Board to a Surrogate

We've finished converting the Outputter into a Surrogate, but we are still left with the board as a dynamic mock, so let's go ahead and convert that.

Rather than just do a simply doing a line-by-line conversion, let's take advantage of the fact that we are using a hand-rolled mock. With any hand-rolled mock, not just Surrogate, you have the option of giving a stubbed method its own behavior rather than simply stubbing its return value. Instead of stubbing has_any_living_cells? to return false after a certain number of true return values, let's be more expressive in our tests by giving our MockBoard some simple behavior. This MockBoard lives for a given number of generations, then is void of any living cells.

In short, we aren't stubbing out the behavior of Board, but instead are swapping Board with an object that has the same interface, but simpler behavior.

require "surrogate/rspec"

class MockBoard
 Surrogate.endow(self)
 define(:has_any_living_cells?) do
 @number_of_remaining_generations > 0
 end
 define(:advance_generation) do
 @number_of_remaining_generations -= 1
 end

 define(:bring_to_life_at) { | location| }
 define(:has_living_cell_at?) { |location| }

 attr_accessor :number_of_remaining_generations
end

Now, when using it, we can simply set the number of remaining generations on the board and then call run.

module GameOfLife
 describe Runner do

 before(:each) do
 @outputer = MockOutputter.new
 @board = MockBoard.new
 @runner = Runner.new(@board, @outputer)
 @runner.stub(:sleep)
 end

 context "with only one remaining generation" do
 before(:each) do
 @board.number_of_remaining_generations = 1
 end

 it "outputs the board" do
 @runner.run
 @outputer.should have_been_told_to(:show_board).with(@board)
 end

 it "advances the board's generation" do
 @runner.run
 @board.should have_been_told_to(:advance_generation)
 end

 it "pauses between generations" do
 @runner.should_receive(:sleep).with(Runner::TIME_BETWEEN_GENERATIONS)
 @runner.run
 end
 end

 context "with multiple remaining generations" do
 before(:each) do
 @board.number_of_remaining_generations = 5
 end

 it "shows the board for each generation" do
 @runner.run
 @outputer.should have_been_told_to(:show_board).times(5)
 end

 it "sleeps between each generation" do
 @runner.should_receive(:sleep).with(Runner::TIME_BETWEEN_GENERATIONS).exactly(5).times
 @runner.run
 end
 end

 end
end

Summary

So, what have we learned between the two parts?

  1. We learned that most uses of RSpec dynamic mocks to simply stub attributes can be easily converted to using Surrogate.
  2. The use of Surrogate provides more immediate and specific feedback when your mocks start diverging from their real counterparts.
  3. While not as easy as converting simple stubs of attributes, most message expectations can be easily converted to use Surrogate.
  4. The use of a hand-rolled mock gives us more flexibility and options when compared to a dynamic mock. Instead of simply stubbing attributes, you can create a simpler version of your real object.
  5. Surrogate is compatible with RSpec dynamic mocks, and is in fact meant to be used with RSpec, which is useful when migrating tests over or for giving Surrogate a chance in an isolated part of your application.

Hopefully, this will help those of you on the fence about using Surrogate having an easier time giving it a try on one of your projects.