In Progress
Unit 1, Lesson 1
In Progress

Instance Spy

Video transcript & code

Recently we wrote an RSpec specification for a Thermostat class. It currently contains three examples. One states that the thermostat turns on a furnace when the temperature gets too low. One that it leaves the furnace off if the temperature is comfortable. (We have yet to specify the important behavior of turning off the furnace if it has been on, something the users will probably want before this software goes to production…). The third example asserts that an exception is raised if the furnace is unable to light.

In true mockist fashion, we wrote these examples before we had written any Furnace class. And, in fact, they helped us to work out the interface that a Furnace would need. Time has passed, and we now have the beginnings of a Furnace class.

require "rspec/autorun"

class Furnace
  def turn_on
     # ...
     true
  end
end

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.00198 seconds (files took 0.10635 seconds to load)
# >> 3 examples, 0 failures
# >>

At this point in development, we decide to make a small change to the Furnace API. The change is this: we want to rename the #turn_on method to #switch_on.

We proceed to rename the method everywhere we find it. Well, almost everywhere. For some reason, we miss a spot. We fail to rename the method in the second example, the one that asserts when the #turn_on message will not be sent.

When we run the tests again, they still pass.

require "rspec/autorun"

class Furnace
  def switch_on
    # ...
  end
end

class Thermostat
  def initialize(thermometer:,furnace:)
    @thermometer = thermometer
    @furnace     = furnace
  end

  def check_temperature
    temp = @thermometer.temp_f
    if temp <= 67
      @furnace.switch_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(:switch_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(switch_on: false)
    thermostat  = Thermostat.new(thermometer: thermometer, furnace: furnace)

    expect { thermostat.check_temperature }.to raise_error
  end

end

# >> ...
# >>
# >> Finished in 0.00349 seconds (files took 0.10203 seconds to load)
# >> 3 examples, 0 failures
# >>

And of course they pass. This example asserts that #turn_on is not sent. And since the method has been renamed, the #turn_on message will never be sent!

This is bad. This is very bad. We now have a test that looks perfectly reasonable at first glance. One that was once an important part of our test suite. One that still passes. And yet, one that is now 100% useless, because it cannot fail.

When it comes to problems with "mockist" testing, this one is the Big Kahuna. In a dynamic language like Ruby, it is far too easy mock or stub the wrong thing and receive no warning whatsoever. This can happen because of an incomplete rename. Or simply as the result of a typo. These types of mistakes are particularly hard to spot in a test like this one, which asserts the absence of a message send, rather than the presence of one.

It is for this reason that recent versions of RSpec have introduced the concept of verifying doubles. A verifying double is a test double object that knows the type of object it is standing in for.

Remember that the spy message is simply a shorthand for double.as_null_object, and returns an RSpec test double which can respond to any message. Let's replace this spy with an instance spy. As an argument, we supply the name of the class that this test double will stand in for.

So long as the named class is loaded, this test double will be able to check any message assertions against the class' list of public methods. When we run the test again, this time we get a failure. Looking at the message, we see the problem spelled out: "Furnace does not implement: turn-on". Our silently misleading success is now a noisy failure.

require "rspec/autorun"

class Furnace
  def switch_on
    # ...
  end
end

class Thermostat
  def initialize(thermometer:,furnace:)
    @thermometer = thermometer
    @furnace     = furnace
  end

  def check_temperature
    temp = @thermometer.temp_f
    if temp <= 67
      @furnace.switch_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(:switch_on)
  end

  it "leaves the furnace when the temperature is comfortable" do
    thermometer = double(temp_f: 72)
    furnace     = instance_spy("Furnace")
    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(switch_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: expect(furnace).to_not have_received(:turn_on)
# >>        Furnace does not implement: turn_on
# >>      # xmptmp-in4470wnr.rb:40:in `block (2 levels) in <main>'
# >>
# >> Finished in 0.00209 seconds (files took 0.08347 seconds to load)
# >> 3 examples, 1 failure
# >>
# >> Failed examples:
# >>
# >> rspec  # Thermostat leaves the furnace when the temperature is comfortable
# >>

When we rename the incorrect message name to one that the instance double recognizes, the tests pass again.

require "rspec/autorun"

class Furnace
  def switch_on
    # ...
  end
end

class Thermostat
  def initialize(thermometer:,furnace:)
    @thermometer = thermometer
    @furnace     = furnace
  end

  def check_temperature
    temp = @thermometer.temp_f
    if temp <= 67
      @furnace.switch_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(:switch_on)
  end

  it "leaves the furnace when the temperature is comfortable" do
    thermometer = double(temp_f: 72)
    furnace     = instance_spy("Furnace")
    thermostat  = Thermostat.new(thermometer: thermometer, furnace: furnace)

    thermostat.check_temperature
    expect(furnace).to_not have_received(:switch_on)
  end

  it "raises an exception when the furnace fails to light" do
    thermometer = double(temp_f: 67)
    furnace     = double(switch_on: false)
    thermostat  = Thermostat.new(thermometer: thermometer, furnace: furnace)

    expect { thermostat.check_temperature }.to raise_error
  end

end

# >> ...
# >>
# >> Finished in 0.00238 seconds (files took 0.08266 seconds to load)
# >> 3 examples, 0 failures
# >>

instance_spy is a part of a whole family of verifying doubles in the RSpec repertoire. I'll include a link to the documentation in the show notes. They are well worth familiarizing yourself with, and I'll probably talk more about them in future episodes.

For now, I just want to expose you to the fact that verifying doubles exist. If you're worried that your RSpec examples may lose value over time due to false negatives, verifying doubles may be able to restore your trust.

Happy hacking!

Responses