mocha icon indicating copy to clipboard operation
mocha copied to clipboard

`with(...) { ... }` should match both given args and block, or reject both

Open sambostock opened this issue 2 years ago • 3 comments

Context

Consider the following code

something.expects(:a_method).with(instance_of(Foo)) do |arg|
  # check something complicated about arg
end

Currently, only the block matcher will be checked, and instance_of(Foo) will be ignored.

This is because specifying expected_parameters and a matching_block are silently mutually exclusive. We can see this by following the code to ParametersMatcher#match?

https://github.com/freerange/mocha/blob/f7e17636ac66c031f1914f7ee4b32b6a3e7d2353/lib/mocha/expectation.rb#L269-L272

https://github.com/freerange/mocha/blob/f7e17636ac66c031f1914f7ee4b32b6a3e7d2353/lib/mocha/parameters_matcher.rb#L5-L18

match? checks if a block was provided, and if so matches against that. It only matches against the expected_parameters if no block was given.

This means it is possible to write the following passing test:

something.expects(:a_method).with(all_of(instance_of(TrueClass), instance_of(FalseClass))) { true }
something.a_method("not a boolean at all")

Proposal

:a: Match both

Match against the parameter matchers and the block. Something like:

def match?(actual_parameters = []) 
  matches_block?(actual_parameters) && parameters_match?(actual_parameters) 
end

def matches_block?(actual_parameters)
  return true if @matching_block.nil?
  @matching_block.call(*actual_parameters)
end

:b: Make mutual exclusion explicit

Raise if passed both expected parameters and a block are given

def with(*expected_parameters_or_matchers, &matching_block)
  raise ArgumentError, "params and block are mutually exclusive" if matching_block && !expected_parameters_or_matchers.empty?
  @parameters_matcher = ParametersMatcher.new(expected_parameters_or_matchers, self, &matching_block)
  self
end

Opinion

My gut feeling is 🅰️ would be better.

sambostock avatar Jan 26 '23 17:01 sambostock

@sambostock

Well spotted and thanks for reporting - I'm amazed someone hasn't come across that before!

I agree with that option A would be better / less surprising. Either way, Mocha will need to display a deprecation warning before the behaviour is changed.

Your question has also made me wonder whether calling Expectation#with ought to combine matching conditions as well, but that's for another day!

floehopper avatar Jan 26 '23 18:01 floehopper

Yeah, I considered that this would be a potentially breaking change. I figured a deprecation + config option to opt-in would be the way to go, regardless of :a: or 🅱️.

whether calling Expectation#with ought to combine matching conditions as well

🤔 I'm not sure I follow what you mean here? Do you mean that with should wrap the args in all_of? I'm guessing not, because that would break multiple parameters.

sambostock avatar Jan 26 '23 18:01 sambostock

whether calling Expectation#with ought to combine matching conditions as well

🤔 I'm not sure I follow what you mean here? Do you mean that with should wrap the args in all_of? I'm guessing not, because that would break multiple parameters.

Sorry I missed a crucial bit in that sentence - it should've read:

calling Expectation#with multiple times ought to combine matching conditions

e.g. something.expects(:a_method).with(has_key(:foo)).with(has_key(:bar)) # => matches something.a_method({foo: 1, bar: 2}) but not something.a_method({foo: 1})

Having written it out more explicitly, perhaps it's unlikely that someone would do it, but it is technically possible. A bit of a hazard with a chain-able API, I guess.

floehopper avatar Jan 26 '23 19:01 floehopper