Episode Script
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:
- With its pattern of a stubbed method yielding a mocked object,
it’s quite difficult to read it and understand what is going on. - It rigidly specifies the implementation of the method under
test. Even as small a change as callingKernel#open
instead of
File.open
, or of using#write
instead of#puts
, will break
the test—even if the method still works correctly! - 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: 180\n")
scores[1].should eq("Avdi: 360\n")
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!