Search code examples
rubyexceptionrspecmocking

RSpec: Why does `instance_double` work with StandardError, but not other exception classes?


In some tests, I want to setup a mock that raises a specific exception class. Because that particular exception is hard to instanciate in a test, I want to use a double.

Here's an example.

class SomeError < StandardError
  def initialize(some, random, params)
    # ...
  end
end

class SomeClass
  def some_method
    mocked_method
    :ok
  rescue SomeError
    :ko
  end

  def mocked_method
    true
  end
end

describe SomeClass do
  subject(:some_class) { described_class.new }

  describe '#some_method' do
    subject(:some_method) { some_class.some_method }

    it { is_expected.to be :ok }

    context 'when #mocked_method fails' do
      before do
        allow(some_class).to receive(:mocked_method)
          .and_raise(instance_double(SomeError))
      end

      it { is_expected.to be :ko }
    end
  end
end

However, running this test fails with the following message.

     Failure/Error:
       mocked_method

     TypeError:
       exception class/object expected

The weird part is that, if I replace SomeError with StandardError, it works just fine.

class SomeClass
  def some_method
    mocked_method
    :ok
  rescue StandardError
    :ko
  end

  def mocked_method
    true
  end
end

describe SomeClass do
  subject(:some_class) { described_class.new }

  describe '#some_method' do
    subject(:some_method) { some_class.some_method }

    it { is_expected.to be :ok }

    context 'when #mocked_method fails' do
      before do
        allow(some_class).to receive(:mocked_method)
          .and_raise(instance_double(StandardError))
      end

      it { is_expected.to be :ko }
    end
  end
end

What is happening here? Is there some edge case when mocking StandardError? Alternatively, is there a better way to mock exception classes that are hard to instanciate?


Solution

  • Issue Explanation

    The TypeError is caused by Kernel#raise.

    RSpec::Mocks::MessageExpectation#and_raise wraps the call to Kernel#raise in a Proc (shown Here) which is called later.

    Kernel#raise accepts the following:

    • No arguments - "raises the exception in $! or raises a RuntimeError if $! is nil"; or
    • A String - "raises a RuntimeError with the string as a message."; or
    • "An Exception or another object that returns an Exception object when sent an exception message" - this Exception will be raised

    In your case instance_double(SomeError) is none of the above and therefor Kernel#raise throws a TypeError. The same can be reproduced with:

    raise({a: 12})
    in `raise': exception class/object expected (TypeError)
    

    Red Herring

    The reason StandardError works is not what you think.

    Instead StandardError merely appears to work because SomeClass#some_method is rescuing StandardError and TypeError is a StandardError (inherits from StandardError). In this case the TypeError is still being raised it is just being rescued in the process.

    You can prove this by changing and_raise(instance_double(StandardError)) to and_raise(instance_double(SomeError)) (or any other argument that does not adhere to the arguments accepted by Kernel#raise) and the code will still pass as long as SomeClass#some_method rescues StandardError or TypeError.

    Solution?

    While I don't fully understand the limitation you are under (e.g. "that particular exception is hard to instanciate in a test"), and would fully recommend just instantiating a SomeError, you could accomplish your goal by simply creating an Exception that inherits from SomeError as a mock.

    class SomeError < StandardError
      def initialize(some, random, params)
        # ...
      end
    end
    
    class MockSomeError < SomeError; def initialize(*);end; end  
    
    class SomeClass
      def some_method
        mocked_method
        :ok
      rescue SomeError
        :ko
      end
    
      def mocked_method
        true
      end
    end
    
    describe SomeClass do
      subject(:some_class) { described_class.new }
    
      describe '#some_method' do
        subject(:some_method) { some_class.some_method }
    
        it { is_expected.to be :ok }
    
        context 'when #mocked_method fails' do
          before do
            allow(some_class).to receive(:mocked_method)
              .and_raise(MockSomeError)
          end
    
          it { is_expected.to be :ko }
        end
      end
    end
    

    Example