Module 5, Topic 1
In Progress

The End of Mocking

Module Progress
0% Complete

As part of my TDD practice, I like to use mock and stub objects to help me drive out a design. One of the most important skills in a "mockist" testing discipline is knowing when to stop mocking.

Consider a class that represents a player in a computer game. We want to test-drive a method which will tally up the player's score and then permanently store it to a list of high scores. We'll be using RSpec for this example.

We start out by setting up the context for the test. We instantiate a Player object and set up its name, and counts of levels finished and coins collected. We then think about how we want to store high scores. We realize that we really have two responsibilities here: one of tallying up the score, and another of writing the score to the filesystem in some form. We decide to defer the decision about how to implement the second responsibility, and instead inject a mock object for the role of "high score storage". We set up the mock with an expectation that it will be invoked with the tallied score. Then we call the method under test with the mock object as an argument.

require 'rspec/autorun'

class Player
  attr_accessor :name, :levels_completed, :tokens_collected

  def initialize(attributes={})
    attributes.each do |key, value|
      public_send("#{key}=", value)
    end
  end
end

describe Player do
  describe "#record_high_score" do
    it "tallies and records the player score" do
      player = Player.new("Avdi", 
        levels_completed: 5, 
        tokens_collected: 17)
      high_score_storage = double
      high_score_storage.should_receive(:save_score)
        .with("Avdi", 670)
      player.record_high_score(high_score_storage)
    end
  end
end

To satisfy this test, we add a #record_high_score method to the Player class. It tallies up the players score, and then uses the passed-in storage collaborator to store it.

require 'rspec/autorun'

class Player
  attr_accessor :name, :levels_completed, :tokens_collected

  def initialize(attributes={})
    attributes.each do |key, value|
      public_send("#{key}=", value)
    end
  end

  def record_high_score(storage)
    score = (levels_completed * 100) + (tokens_collected * 10)
    storage.save_score(name, score)
  end
end

describe Player do
  describe "#record_high_score" do
    it "tallies and records the player score" do
      player = Player.new(
        name: "Avdi", 
        levels_completed: 5, 
        tokens_collected: 17)
      high_score_storage = double
      high_score_storage.should_receive(:save_score)
        .with("Avdi", 670)
      player.record_high_score(high_score_storage)
    end
  end
end

# >> .
# >> 
# >> Finished in 0.0004 seconds
# >> 1 example, 0 failures

Now we need to figure out how we will actually write this data to the filesystem. We start to describe a HighScoreFile class to handle this responsibility.

describe HighScoreFile do
  describe "#save_score" do

  end
end

One way we could specify the behavior of the #save_score method is to mock out its collaborators, just as we did in the last test. Perhaps something like this. We create a "file" double to stand in for the file which will be written. Then we stub out Ruby's File.open method to yield the double, qualifying the stub to only take effect if a specific filename is given. We set an expectation that the new high score will be written to the file using #puts. Finally we instantiate a HighScoreFile object with that filename, and send it the #save_score message.

describe HighScoreFile do
  describe "#save_score" do
    it "appends the player name and score to a file" do
      file = double
      File.stub(:open).with("THE_FILE").and_yield(file)
      file.should_receive(:puts).with("Avdi: 360")
      high_score_file = HighScoreFile.new("THE_FILE")
      high_score_file.save_score("Avdi", 360)
    end
  end
end

Then we proceed to implement the class according to the test:

require 'rspec/autorun'

class HighScoreFile
  def initialize(filename)
    @filename = filename
  end

  def save_score(player_name, score)
    File.open(@filename) do |file|
      file.puts("#{player_name}: #{score}")
    end
  end
end

describe HighScoreFile do
  describe "#save_score" do
    it "appends the player name and score to a file" do
      file = double
      File.stub(:open).with("THE_FILE").and_yield(file)
      file.should_receive(:puts).with("Avdi: 360")
      high_score_file = HighScoreFile.new("THE_FILE")
      high_score_file.save_score("Avdi", 360)
    end
  end
end
# >> .
# >> 
# >> Finished in 0.00099 seconds
# >> 1 example, 0 failures

Let's take a close look at that test. Here are some observations we can make about it:

  1. With its pattern of a stubbed method yielding a mocked object, it's quite difficult to read it and understand what is going on.
  2. It rigidly specifies the implementation of the method under test. Even as small a change as calling Kernel#open instead of File.open, or of using #write instead of #puts, will break the test—even if the method still works correctly!
  3. It tests that the code interacts as expected with a fake version of Ruby's I/O APIs. It does not test that the code actually writes to a file. And, in fact, the code as driven by this test has a bug: it fails to set the mode correctly when opening the file. Since Ruby defaults to opening files in read-only mode, this code would fail if we actually used it to write out some high scores.

In fact, this test violates one of the basic guidelines for using mocks well: only mock what you own. We don't own Ruby's I/O libraries, and by mocking them out we've wound up with a test that has very little value. In fact, by making it hard to change the code under test, and by giving us a false sense of security, this test could reasonably be said to have negative value.

When we reach the border between our code and code outside our control, it's time to stop mocking and stubbing. Let's rewrite this test to show that the HighScoreFile class can actually do its job.

We start by opening up a temp file. We write a previous player's score to this file, so that we can later test that the #save_score method appends scores to the file, rather than overwriting it. Then we close the file, and instantiate a HighScoreFile object with the path of the temp file. We call the #save_score method with a name and a score. Then we re-open the temp file, read in its content as an array of lines, and verify that it contains both its original content as well as the new score.

We run the test, and see that it fails because of the incorrect file mode. We fix the code to open the score file in "append" mode, and re-run the test. This time it passes.

require 'rspec/autorun'
require 'tempfile'

class HighScoreFile
  def initialize(filename)
    @filename = filename
  end

  def save_score(player_name, score)
    File.open(@filename, 'a') do |file|
      file.puts("#{player_name}: #{score}")
    end
  end
end

describe HighScoreFile do
  describe "#save_score" do
    it "appends the player name and score to a file" do
      file = Tempfile.open('w')
      file.puts("PreviousPlayer: 180")
      file.close
      high_score_file = HighScoreFile.new(file.path)
      high_score_file.save_score("Avdi", 360)
      file.open
      scores = file.readlines
      scores[0].should eq("PreviousPlayer: 180n")
      scores[1].should eq("Avdi: 360n")
    end
  end
end
# >> .
# >> 
# >> Finished in 0.00125 seconds
# >> 1 example, 0 failures

Now that we know that it works, we go back and update the Player class so that it defaults to using a HighScoreFile to save high scores.

class Player
  # ...

  def record_high_score(storage=HighScoreFile.new("~/.highscores"))
    score = (levels_completed * 100) + (tokens_collected * 10)
    storage.save_score(name, score)
  end
end

Our HighScoreFile is an example of an adapter class. It connects our domain logic, in this case represented by the Player class, to outside APIs, in this case Ruby's file I/O system. Because they deal with external APIs from code we don't own, adapter classes are poor candidates for a "mockist" style of testing. It is better to test them in an integration style. This will quickly show us when we've mis-used the external API they are intended to adapt.

That's all for today. Happy hacking!

Responses