In Progress
Unit 1, Lesson 21
In Progress

Custom Exception

Video transcript & code

In the last episode we talked about testing exceptions, and why it's important to be specific—but not too specific—about the exception we expect to be raised. We ended up with some code that looks like this:

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_onor fail "Furnace could not be lit"
    end
  end
end

RSpec.describe Thermostat do
  # ...

  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(/furnace could not be lit/i)
  end

end

# >> .
# >>
# >> Finished in 0.00114 seconds (files took 0.08665 seconds to load)
# >> 1 example, 0 failures
# >>

This test asserts that under the right circumstances, a particular exception will be raised. Well, actually, that an exception with a particular message will be raised. We use a case-insensitive regular expression to add a little bit of "fuzziness" to the matching.

Even with the regular expression, however, this test is still a bit sensitive to minor changes in wording. And this brittleness is indicative of a bigger issue.

Why do we care at all about the kind of error that is raised? We care because ultimately, we might want to take action based on the error. Either a human being is going to read the message and do something as a result; or some other part of the program is going to take action when it sees this exception.

Let's talk about the latter case, where the program acts based on an exception that is raised. Unlike RSpec matchers, Ruby does not have a simple, built-in way for exceptions to be matched based on their message. We can't supply a regular expression to a Ruby rescue statement.

rescue /furnace could not be lit/i
  # ...
end

When we make decisions about errors in code, we need to use something more clear-cut than text matching. We need to switch on an error's type. Which means that we need errors to have distinct types.

When we simply write fail or raise in a Ruby program, the default type of the error that gets raised is RuntimeError. We can see this if we rescue an exception and then inspect it.

error = begin
          fail "Something bad happened"
        rescue => error
          error
        end

error # => #<RuntimeError: Something bad happened>

But we can change this with an extra leading argument. For instance, here we raise ArgumentError, which is a built-in exception type that Ruby uses when calling some methods with the wrong kinds of arguments.

error = begin
          fail ArgumentError, "Something bad happened"
        rescue => error
          error
        end

error # => #<ArgumentError: Something bad happened>

We can define our own exception classes to represent our own errors. For instance, we could create a FurnaceLightingError class. However, when we try to raise it, we run into a problem.

class FurnaceLightingError
end

begin
  raise FurnaceLightingError
rescue => error
  error
end

error                           # => #<TypeError: exception class/object expected>

Instead of our error being raised, we see a TypeError instead. This is because Ruby has a rule that only objects which inherit from the Exception base class can be raised as exceptions.

That's fine, we can fix that by inheriting from the Exception base class.

class FurnaceLightingError < Exception
end

This will work, but I don't recommend it. Exceptions inheriting from the base Exception class are handled differently by Ruby rescue clauses. We'll talk about the specifics in another episode, but what you should remember is this: exception classes inheriting directly from the base Exception class should be reserved for uniquely catastrophic cases; cases where the program could not be expected to continue. When we look at the core classes that inherit directly from Exception we can see this: classes like SystemStackError, NoMemoryError, and ScriptError represent events that are ordinarily impossible to recover from.

ObjectSpace.each_object(Class).select{|klass| klass.ancestors[1] == Exception}
# => [SystemStackError, NoMemoryError, SecurityError, ScriptError, StandardEr...

Instead, I normally inherit my custom error classes from StandardError. This is the class that the Ruby's non-fatal exceptions generally inherit from.

class FurnaceLightingError < StandardError
end

Once we start creating our own error classes, it's easy to go nuts and make a custom exception classes for every possible error. But in practice I haven't found this to be a helpful habit. In fact, I usually start with just a single error type for a given application or library.

Let's say our Thermostat code lives inside a larger ClimateControl module. In this case, I would normally create a single custom exception class, called Error, inside that namespace.

require "rspec/autorun"

module ClimateControl

  class Error < StandardError
  end

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

    def check_temperature
      temp = @thermometer.temp_f
      if temp <= 67
        @furnace.turn_onor fail "Furnace could not be lit"
      end
    end
  end

  RSpec.describe Thermostat do
    # ...

    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(/furnace could not be lit/i)
    end

  end
end
# >> .
# >>
# >> Finished in 0.00114 seconds (files took 0.08665 seconds to load)
# >> 1 example, 0 failures
# >>

I give it the generic-seeming name Error because, since it is inside a namespace, it is implicitly a ClimateControl::Error.

module ClimateControl
  class Error
  end
end

ClimateControl::Error           # => ClimateControl::Error

With a custom error class in place, we can now raise it instead of a generic RuntimeError. And we can specify it as the type of error we expect to be raised when the furnace won't turn on. Note that since the RSpec example is written inside the ClimateControl module, we don't have to spell out the fully-qualified name of the error type.

require "rspec/autorun"

module ClimateControl

  class Error < StandardError
  end

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

    def check_temperature
      temp = @thermometer.temp_f
      if temp <= 67
        @furnace.turn_onor fail Error, "Furnace could not be lit"
      end
    end
  end

  RSpec.describe Thermostat do
    # ...

    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(Error)
    end

  end
end

# >> .
# >>
# >> Finished in 0.00142 seconds (files took 0.13485 seconds to load)
# >> 1 example, 0 failures
# >>

We've removed the text matching part of this assertion, now that we are checking for a library-specific exception. But if we wanted, we could check for both the exception type and the right message, by passing two arguments to the RSpec raise_error assertion.

expect { thermostat.check_temperature }
  .to raise_error(Error, /furnace could not be lit/i)

Of course, this has all the test-brittleness problems that we talked about at the beginning of this episode. In many cases it may be sufficient just to assert that the library or app-specific exception type is raised.

Outside of our tests, the practical advantage we now have is that we can differentiate between ClimateControl-specific errors and other types of errors. This means we can handle them in a particular way - perhaps logging them, or reporting them to the user differently.

begin
  # some code...
rescue ClimateControl::Error => error
  # ...
rescue => error # other kinds of error
  # ...
end

A single custom exception type for our application or library is just a start. We still may want to refine types of errors a bit further. But that is a topic we can talk about on another day. Happy hacking!

Responses