In Progress
Unit 1, Lesson 21
In Progress

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.

Happy hacking!

Responses