Let's say we're writing a library to wrap the UNIX at
command, which schedules jobs to be run at a later time. We already have a lower-level method for executing arbitrary commands, called execute
. It takes a command, an optional list of flags, and an optional string to write to the command's STDIN
. It executes the command, and returns a data structure that combines an ExitStatus
object and the output of the command.
CommandResult = Struct.new(:status, :output)
class Shell
def execute(command, flags=[], input=nil)
result = CommandResult.new
IO.popen([command, *flags], 'w+', err: [:child, :out]) do |io|
io.write(input) if input
io.close_write
result.output = io.read
end
result.status = $?
result
end
end
We decide to drive these methods test-first using RSpec. First, we write a test for the at
method. It should execute the at
command, passing it the given time specifier and command arguments. This is straightforward enough to test: we mock out the .execute
method to expect those arguments.
describe "#at" do
it "executes `at` with the given time and command" do
Shell.should_receive(:execute).
with("at", ["now + 3 minutes"], "espeak 'tea is ready!'")
at("now + 3 minutes", "espeak 'tea is ready!'")
end
end
This test is easy enough to satisfy.
def at(timespec, what)
Shell.execute("at", [timespec], what)
end
This is a command method: it causes some change to happen in the world around it.
Next we specify the behavior of the atq
method, which should return a list of the currently scheduled jobs. This time, we stub the .execute
method to return a fake command result. We take the fact that the #atq
method returns part of that fake result as sufficient proof that execute
was called correctly.
describe "#atq" do
it "executes returns the output of `atq`" do
result = double(output: "THE OUTPUT")
Shell.stub(execute: result)
atq.should eq("THE OUTPUT")
end
end
This is even easier to make pass. We execute the atq
command and return the resulting output.
def atq
Shell.execute('atq').output
end
This is an example of a query method: it's job is to return some information.
When the at
command is successful, it normally outputs a line of confirmation text showing exactly when the command is scheduled to be run, as well as the job's numeric ID. We realize we'd like to make the scheduled job's ID available to callers of #at
. So we add a new example to our spec.
In this example, we stub the .execute
method to return some faked-up command results, and verify that the #at
method extracts the ID and returns it. We already have a test to verify that #at
calls .execute
with the right arguments, so we won't repeat any of that here.
describe "#at" do
it "executes `at` with the given time and command" do
Shell.should_receive(:execute).
with("at", ["now + 3 minutes"], "espeak 'tea is ready!'")
at("now + 3 minutes", "espeak 'tea is ready!'")
end
it "returns the job ID of the scheduled job" do
result = double(output: "job 42 at Sun Oct 14 20:15:00 2012")
Shell.stub(execute: result)
at("some time", "some job").should eq(42)
end
end
We then modify #at
to extract and return the job ID using a regular expression match.
def at(timespec, what)
result = Shell.execute("at", [timespec], what)
result.output.match(/Ajob (d+)/)[1].to_i
end
But now we have a problem:
Failures: 1) #at executes `at` with the given time and command Failure/Error: Unable to find matching line from backtrace NoMethodError: undefined method `output' for nil:NilClass # at_spec_3.rb:20:in `at' # at_spec_3.rb:27:in `block (2 levels) in'
Our original test for #at
is now failing. It's failing because the method now uses the return value of .execute
. And since the original test is only interested in what the #at
method invokes, not what it returns, it doesn't bother to set up a realistic return value for the .execute
mock.
To make this pass, we have to update the old test, adding a return value to the should_receive
:
describe "#at" do
it "executes `at` with the given time and command" do
Shell.should_receive(:execute).
with("at", ["now + 3 minutes"], "espeak 'tea is ready!'").
and_return(double(output: "job 42 at Sun Oct 14 20:15:00 2012"))
at("now + 3 minutes", "espeak 'tea is ready!'")
end
it "returns the job ID of the scheduled job" do
result = double(output: "job 42 at Sun Oct 14 20:15:00 2012")
Shell.stub(execute: result)
at("some time", "some job").should eq(42)
end
end
Let's take a step back. We just added some extra context to our first test of #at
which is completely irrelevant to that test. We did this just to keep the tests passing. From now on, we're going to have to keep doing this every time we make a new assertion about what #at
does. And if #at
ever starts using more of the return value from .execute
, we may have to add even more unrelated boilerplate to our mocks, just to keep the tests passing.
This is the kind of thing that causes "mockist" tests to be accused of being brittle, and rightly so. However, before we throw away our mocks, let's take a look at what this might be telling us about our design.
Earlier, I mentioned that the #at
method was a command method, and the #atq
method was a query method. This is no longer true. The #at
method is now both a command and a query. This violates the principle of command-query separation, which states that keeping commands and queries strictly separated in a program makes that program simpler and more comprehensible. Another way I think about this principle is that when sent a message, you can "pay it back" or "pay it forward", but never both.
Let's change #at
to "pay it forward". Instead of returning the job ID, we'll make it optionally yield the job ID.
def at(timespec, what)
result = Shell.execute("at", [timespec], what)
if block_given?
yield result.output.match(/Ajob (d+)/)[1].to_i
end
end
Now when we call #at
and we care about the job ID, we pass a block to it:
at("now + 25 minutes", "espeak 'Pomodoro!'") do |id|
puts "The job ID is #{id}"
end
Finally, we update the spec again. In the first example, we remove the unneeded return value from the mocked .execute
. Then we change the second example to test for a yielded value instead of a return.
describe "#at" do
it "executes `at` with the given time and command" do
Shell.should_receive(:execute).
with("at", ["now + 3 minutes"], "espeak 'tea is ready!'")
at("now + 3 minutes", "espeak 'tea is ready!'")
end
it "yields the job ID of the scheduled job" do
result = double(output: "job 42 at Sun Oct 14 20:15:00 2012")
Shell.stub(execute: result)
job_id = nil
at("some time", "some job") do |id| job_id = id end
job_id.should eq(42)
end
end
By converting a return value to a rudimentary callback, we've once again made the #at
method a pure command
. We push values into it, and values are in turn pushed forward, into the .execute
method, and optionally into a callback block.
In future episodes we'll return regularly to the idea of command-query separation, and take closer looks at why methods in which information moves in only one direction—either forwards to other collaborators, or backwards to the caller—can make a design simpler and more composable. For now, I just hope you have a better sense for when your tests are telling you that commands and queries are being mixed together.
Responses