Friday, March 23, 2007

Ruby: === operator

Recently I was looking at creating a patch for Mocha that would allow you to specify a class as an argument. The feature was added to allow you to specify that an argument can be any instance of the class specified.
object.expects(:do_this).with(Fixnum, true, 99)
object.do_this(2, true, 99) # satisfies the expectation
To support this new feature I looked into the === method.

The === method of Object is defined as:
Case Equality—For class Object, effectively the same as calling #==, but typically overridden by descendents to provide meaningful semantics in case statements.
There's no mystery here, use === like ==. However, the === method of Module provides the behavior I'm looking for.
Case Equality—Returns true if anObject is an instance of mod or one of mod‘s descendents. Of limited use for modules, but can be used in case statements to classify objects by class.
The documentation can be tested easily to ensure the behavior I'm looking for.
Fixnum === 2 #=> true
An important thing to note is that 2 and Fixnum are not commutative.
2 === Fixnum #=> false
So, using the === method I was able create the following patch that provides the behavior I was looking for.
Index: trunk/test/unit/expectation_test.rb
===================================================================
--- trunk/test/unit/expectation_test.rb (revision 109)
+++ trunk/test/unit/expectation_test.rb (working copy)
@@ -30,6 +30,16 @@
assert expectation.match?(:expected_method, 1, 2, 3)
end

+ def test_should_match_calls_to_same_method_with_expected_parameter_values_or_class
+ expectation = new_expectation.with(1, Fixnum, Object)
+ assert expectation.match?(:expected_method, 1, 2, 3)
+ end
+
+ def test_should_match_calls_to_same_method_with_expected_parameter_values_or_class
+ expectation = new_expectation.with(1, Fixnum, Object)
+ assert expectation.match?(:expected_method, 1, Fixnum, 3)
+ end
+
def test_should_match_calls_to_same_method_with_parameters_constrained_as_expected
expectation = new_expectation.with() {|x, y, z| x + y == z}
assert expectation.match?(:expected_method, 1, 2, 3)
Index: trunk/lib/mocha/expectation.rb
===================================================================
--- trunk/lib/mocha/expectation.rb (revision 109)
+++ trunk/lib/mocha/expectation.rb (working copy)
@@ -22,9 +22,6 @@
class InvalidExpectation < Exception; end

class AlwaysEqual
- def ==(other)
- true
- end
end

attr_reader :method_name, :backtrace
@@ -43,7 +40,15 @@
end
def match?(method_name, *arguments)
- (@method_name == method_name) and (@parameter_block ? @parameter_block.call(*arguments) : (@parameters == arguments))
+ return false unless @method_name == method_name
+ return @parameter_block.call(*arguments) unless @parameter_block.nil?
+ return true if @parameters.is_a? AlwaysEqual
+ return false unless @parameters.size == arguments.size
+ @parameters.inject([0, true]) do |result, arg|
+ result[1] &&= (arguments[result.first].is_a?(Module) ? arg == arguments[result.first] : arg === arguments[result.first])
+ result[0] += 1
+ result
+ end.last
end
# :startdoc:

8 comments:

  1. If I read the tests correctly, expecting Fixnum will match either Fixnum (the actual Class object) or an instance of Fixnum. Am I correct? If so, does this bug you at all?

    ReplyDelete
  2. David,

    I intentionally added that behavior since:

    Fixnum === Fixnum #=> false

    The ambiguity does bother me, but not being able to test for the value Fixnum seemed like a worse option. I think the real long term solution will be more complicated.

    ReplyDelete
  3. We use argument matchers in rspec. To be honest, I don't like our implementation (mine ;) ), but I just submitted a patch to mocha that would allow you to do this:

    obj.expects(:message).with(is_a(Fixnum))

    Whether or not James accepts the patch, I'd love your feedback on it.

    Cheers,
    David

    ReplyDelete
  4. David,

    I like the concept in general, but I think is_a reads poorly. I would prefer an (admittedly more verbose) an_instance_of method. I think the mock definition would read better as

    obj.expects(:foo).with(an_instance_of(Fixnum), an_instance_of(String)

    ReplyDelete
  5. Hi Jay - I agree with you in this case in isolation. It does concern me that things could start getting quite verbose as more matchers are added:

    obj.expects(:msg).with(an_instance_of(String), a_value_greater_than(5))

    In this case, I start wanting to see:

    obj.expects(:msg).with(an_instance_of(String)).and( a_value_greater_than(5))

    But that starts to really complicate things. There's a point of diminishing returns when it comes to increasing readability. I'm not sure where that point lies here.

    WDYT?

    ReplyDelete
  6. I can speculate, but I think I'd need to get tired of typing before I decided something was too verbose. As far as adding the and, I don't see it as necessary since the ',' in the with arguments already currently plays the 'and' role.

    ReplyDelete
  7. OK - I posted a second patch with an_instance_of. We'll have to see what James has to say. He may not like the idea to begin with, although the implementation doesn't really change anything internal to mocha, so it can ship separately.

    Thanks for the feedback Jay.

    Cheers,
    David

    ReplyDelete
  8. Good stuff guys. Sorry for delayed response - I've been on holiday. I'll look at getting something committed asap.

    ReplyDelete

Note: Only a member of this blog may post a comment.