Test Spies
Video transcript & code
In the previous episode, we ended up with this RSpec specification. It has three examples, two of which make message assertions. One asserts that under the right circumstances, a certain message is sent. The other asserts that under other circumstances, the message is not sent.
require "rspec/autorun"
class Thermostat
def initialize(thermometer:,furnace:)
@thermometer = thermometer
@furnace = furnace
end
def check_temperature
temp = @thermometer.temp_f
if temp <= 67
@furnace.turn_on or fail "Furnace could not be lit"
end
end
end
RSpec.describe Thermostat do
it "turns on the furnace when the temperature is too low" do
thermometer = double(temp_f: 67)
furnace = double(turn_on: true)
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
expect(furnace).to receive(:turn_on)
thermostat.check_temperature
end
it "leaves the furnace when the temperature is comfortable" do
thermometer = double(temp_f: 72)
furnace = double
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
expect(furnace).to_not receive(:turn_on)
thermostat.check_temperature
end
it "raises an exception when the furnace fails to light" do
thermometer = double(temp_f: 67)
furnace = double(turn_on: false)
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
expect { thermostat.check_temperature }.to raise_error
end
end
# >> ...
# >>
# >> Finished in 0.01013 seconds (files took 0.09661 seconds to load)
# >> 3 examples, 0 failures
# >>
These examples are short, and they each assert just one logical fact about the code under test. But still suffer from a readability problem common to all tests that use message expectations.
The classic order for a unit test has three parts: Arrange, Act, and Assert. First, we arrange the test scenario. Then we act: we tell the objects to do something. Finally, we assert that the outcome is what we expected.
But these tests don't follow this order. They start out OK, by arranging a scenario. But then they immediately assert, by placing a message expectation. And finally they act.
This puts the cart before the horse. While message expectations can be very powerful, they just aren't as pleasant to read as other kinds of tests.
Happily, we don't have to use preemptive message expectations if we don't want to. In recent versions, RSpec also supports test spies, which enable us to make the same types of assertion but using a more comfortable order.
We don't have to make many changes to use spies, either. Because as it turns out, every RSpec test double is also, implicitly, a test spy.
For the first test, all we have to do is swap the assertion line and the "acting" line, then update the assertion from saying "should receive" to say "should have received". That's it; we are now using the test double as a spy. Because we set up a default return value for the #turn_on
message on this double, RSpec will automatically record when these messages are sent to the double. When we retroactively assert that the message was sent, RSpec just looks through this recorded history to find out if the message was, in fact, sent.
require "rspec/autorun"
class Thermostat
def initialize(thermometer:,furnace:)
@thermometer = thermometer
@furnace = furnace
end
def check_temperature
temp = @thermometer.temp_f
if temp <= 67
@furnace.turn_on or fail "Furnace could not be lit"
end
end
end
RSpec.describe Thermostat do
it "turns on the furnace when the temperature is too low" do
thermometer = double(temp_f: 67)
furnace = double(turn_on: true)
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
thermostat.check_temperature
expect(furnace).to have_received(:turn_on)
end
it "leaves the furnace when the temperature is comfortable" do
thermometer = double(temp_f: 72)
furnace = double
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
expect(furnace).to_not receive(:turn_on)
thermostat.check_temperature
end
it "raises an exception when the furnace fails to light" do
thermometer = double(temp_f: 67)
furnace = double(turn_on: false)
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
expect { thermostat.check_temperature }.to raise_error
end
end
# >> ...
# >>
# >> Finished in 0.01398 seconds (files took 0.14361 seconds to load)
# >> 3 examples, 0 failures
# >>
When we try to make the same change to the next test, we run into an error.
require "rspec/autorun"
class Thermostat
def initialize(thermometer:,furnace:)
@thermometer = thermometer
@furnace = furnace
end
def check_temperature
temp = @thermometer.temp_f
if temp <= 67
@furnace.turn_on or fail "Furnace could not be lit"
end
end
end
RSpec.describe Thermostat do
it "turns on the furnace when the temperature is too low" do
thermometer = double(temp_f: 67)
furnace = double(turn_on: true)
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
thermostat.check_temperature
expect(furnace).to have_received(:turn_on)
end
it "leaves the furnace when the temperature is comfortable" do
thermometer = double(temp_f: 72)
furnace = double
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
thermostat.check_temperature
expect(furnace).to_not have_received(:turn_on)
end
it "raises an exception when the furnace fails to light" do
thermometer = double(temp_f: 67)
furnace = double(turn_on: false)
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
expect { thermostat.check_temperature }.to raise_error
end
end
# >> .F.
# >>
# >> Failures:
# >>
# >> 1) Thermostat leaves the furnace when the temperature is comfortable
# >> Failure/Error: $SiB.record_result(34, (expect(furnace).to_not have_...
# >> Double expected to have received turn_on, but that object is not ...
# >> # xmptmp-in21220gR.rb:34:in `block (2 levels) in <main>'
# >>
# >> Finished in 0.00288 seconds (files took 0.10377 seconds to load)
# >> 3 examples, 1 failure
# >>
# >> Failed examples:
# >>
# >> rspec # Thermostat leaves the furnace when the temperature is comfortable
# >>
This is because in this case, since we aren't expecting the #turn_on
message to be sent, we didn't bother setting it up with a default return value. Since RSpec doesn't know we are interested in this message, it doesn't configure our test double to record calls—or the lack of calls—to this method.
The most obvious way to fix this is to go ahead and tell the double that it might receive the #turn_on
message.
require "rspec/autorun"
class Thermostat
def initialize(thermometer:,furnace:)
@thermometer = thermometer
@furnace = furnace
end
def check_temperature
temp = @thermometer.temp_f
if temp <= 67
@furnace.turn_on or fail "Furnace could not be lit"
end
end
end
RSpec.describe Thermostat do
it "turns on the furnace when the temperature is too low" do
thermometer = double(temp_f: 67)
furnace = double(turn_on: true)
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
thermostat.check_temperature
expect(furnace).to have_received(:turn_on)
end
it "leaves the furnace when the temperature is comfortable" do
thermometer = double(temp_f: 72)
furnace = double(turn_on: true)
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
thermostat.check_temperature
expect(furnace).to_not have_received(:turn_on)
end
it "raises an exception when the furnace fails to light" do
thermometer = double(temp_f: 67)
furnace = double(turn_on: false)
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
expect { thermostat.check_temperature }.to raise_error
end
end
# >> ...
# >>
# >> Finished in 0.00213 seconds (files took 0.08984 seconds to load)
# >> 3 examples, 0 failures
# >>
But another way to do it is to configure the double as a null object double. This kind of double is set up to respond to and record any possible message.
require "rspec/autorun"
class Thermostat
def initialize(thermometer:,furnace:)
@thermometer = thermometer
@furnace = furnace
end
def check_temperature
temp = @thermometer.temp_f
if temp <= 67
@furnace.turn_on or fail "Furnace could not be lit"
end
end
end
RSpec.describe Thermostat do
it "turns on the furnace when the temperature is too low" do
thermometer = double(temp_f: 67)
furnace = double.as_null_object
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
thermostat.check_temperature
expect(furnace).to have_received(:turn_on)
end
it "leaves the furnace when the temperature is comfortable" do
thermometer = double(temp_f: 72)
furnace = double(turn_on: true)
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
thermostat.check_temperature
expect(furnace).to_not have_received(:turn_on)
end
it "raises an exception when the furnace fails to light" do
thermometer = double(temp_f: 67)
furnace = double(turn_on: false)
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
expect { thermostat.check_temperature }.to raise_error
end
end
# >> ...
# >>
# >> Finished in 0.00258 seconds (files took 0.11212 seconds to load)
# >> 3 examples, 0 failures
# >>
As it turns out, RSpec has a shortcut syntax for setting up null object doubles. Instead of telling RSpec to give us a double
, we can tell us to give us a spy
. That's all we need; we now have a null object test double which can respond to and record any message sent to it.
require "rspec/autorun"
class Thermostat
def initialize(thermometer:,furnace:)
@thermometer = thermometer
@furnace = furnace
end
def check_temperature
temp = @thermometer.temp_f
if temp <= 67
@furnace.turn_on or fail "Furnace could not be lit"
end
end
end
RSpec.describe Thermostat do
it "turns on the furnace when the temperature is too low" do
thermometer = double(temp_f: 67)
furnace = double.as_null_object
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
thermostat.check_temperature
expect(furnace).to have_received(:turn_on)
end
it "leaves the furnace when the temperature is comfortable" do
thermometer = double(temp_f: 72)
furnace = spy
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
thermostat.check_temperature
expect(furnace).to_not have_received(:turn_on)
end
it "raises an exception when the furnace fails to light" do
thermometer = double(temp_f: 67)
furnace = double(turn_on: false)
thermostat = Thermostat.new(thermometer: thermometer, furnace: furnace)
expect { thermostat.check_temperature }.to raise_error
end
end
# >> ...
# >>
# >> Finished in 0.00185 seconds (files took 0.07126 seconds to load)
# >> 3 examples, 0 failures
# >>
Understand that the spy
method is merely a shorthand for double.as_null_object
. As we said before, RSpec doubles are implicitly equipped to be spies, so there is no need for spy
to return a special kind of object.
Spies give us the best of both worlds: the ability to set arbitrary assertions about which messages are sent, but without the awkward order of a traditional message expectation. There are a few situations in which test spies are insufficient, and we need to fall back on traditional message expectations. But in most common scenarios where we might have used a message expectation, we can now use spies instead.
Responses