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?
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:
$!
or raises a RuntimeError
if $!
is nil
"; orRuntimeError
with the string as a message."; orException
or another object that returns an Exception
object when sent an exception
message" - this Exception
will be raisedIn 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