Ruby Exceptions Equality
… and check why 5600+ Rails engineers read also this
Ruby Exceptions Equality
Few days ago my colleague was stuck for a moment when testing one service object. The service object was responsible for a batch operation. Basically operating on multiple objects and collecting the status of the action. Either it was successful or failed with an exception. We couldn’t get our equality assertion to work even though at first glance everything looked ok. We had to dig deeper.
The problem boils down to exceptions equality in Ruby. And few tests in console showed precisely how it works, later to be confirmed by the documentation.
Let’s see a comparison case by case. But first, exception definition:
RefundNotAllowed = Class.new(StandardError)
Does two instances of same exception equal?
RefundNotAllowed.new == RefundNotAllowed.new
# => true
Yes. That was our first test and it behaved according to our intution. So why did our test fail if everything told us that we are comparing identical exceptions.
What about message?
RefundNotAllowed.new("one message") == RefundNotAllowed.new("another")
# => false
RefundNotAllowed.new("one message") == RefundNotAllowed.new("one message")
# => true
Ok, so apparently the message must be identical as well. But in our case the message was equal and our exceptions were still non-equal. Bummer. Let’s think about one more aspect of exceptions: backtrace.
What about backtrace?
The backtrace of unthrown exception is…
RefundNotAllowed.new.backtrace
=> nil
Ok, I didn’t excepted that. I imagined that the backtrace is assigned at the moment of exception instantiation. But when you think deeper about it, you might realize that it wouldn’t make sense.
exception = RefundNotAllowed.new
raise exception
Would you like to know that the exception was raised at line 1
or rather
line 2
in that example? So obviously backtraces are assigned when
exception is actually raised, not merly instantiated.
But do they play any role in exception equality? Let’s see.
def one_method
raise RefundNotAllowed.new
rescue => x
return x
end
def another_method
raise RefundNotAllowed.new
rescue => x
return x
end
one_method == one_method # => true
another_method == another_method # => true
one_method == another_method # => false
exception_one = one_method
exception_two = one_method
exception_one == exception_two # => false
Apparently for two exceptions to be equal they must have identical backtrace. Even 1 line of difference makes them, well… , different.
What does the doc say?
ruby Exception#==
documentation says:
If obj
is not an Exception, returns false. Otherwise, returns true if exc
and obj
share same
class, messages, and backtrace.
In my original problem it lead me to realization that we were comparing raised-and-catched exception (thus with stacktrace) with a newly instantiated exception. That’s why we couldn’t get to make them equal.
When you use assert_raises(RefundNotAllowed)
or
expect{}.to raise_error(RefundNotAllowed)
these matchers take care of
the details for you:
But when you check something like
expect(result.first.error).to eq(RefundNotAllowed.new)
because your batch
process collected them for you, then you are on your own and this might not
be good enough and it won’t work. You might wanna just compare manually
exception class and message.
What about custom exceptions with additional data?
Because they inherit from Exception
(through StandardError
) they share identical
logic as described in documentation.
class RefundNotAllowed < StandardError
attr_reader :order_id
def initialize(order_id)
super("Refund not allowed")
@order_id = order_id
end
end
RefundNotAllowed.new(1) == RefundNotAllowed.new(2)
# => true
If you want something better you need to overwrite ==
operator yourself.
class RefundNotAllowed < StandardError
attr_reader :order_id
def initialize(order_id)
super("Refund not allowed")
@order_id = order_id
end
def ==(obj)
super(obj) && order_id == obj.order_id
end
end
RefundNotAllowed.new(1) == RefundNotAllowed.new(2) # false
RefundNotAllowed.new(2) == RefundNotAllowed.new(2) # true
My opinion
I am personally not convinced about the usability of including backtrace in exception equality logic because in reality one would almost never create two exceptions with the exact same backtrace to compare them. Although maybe for some usecases it is a nice way to determine if repeated attempt failed in exactly same way and for the same reason.
But you can always overwrite ==
in a way that would not call super
and
would completely ignore the backtrace, instead comparing only exception class, data,
and perhaps message.