Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
sambostock opened this issue Jan 26, 2023 · 3 comments
Open
Assignees

Comments

@sambostock
Copy link

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?

def with(*expected_parameters_or_matchers, &matching_block)
@parameters_matcher = ParametersMatcher.new(expected_parameters_or_matchers, self, &matching_block)
self
end

class ParametersMatcher
def initialize(expected_parameters = [ParameterMatchers::AnyParameters.new], expectation = nil, &matching_block)
@expected_parameters = expected_parameters
@expectation = expectation
@matching_block = matching_block
end
def match?(actual_parameters = [])
if @matching_block
@matching_block.call(*actual_parameters)
else
parameters_match?(actual_parameters)
end
end

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

🅰️ 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

🅱️ 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.

@floehopper floehopper self-assigned this Jan 26, 2023
@floehopper
Copy link
Member

@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!

@sambostock
Copy link
Author

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 🅰️ 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.

@floehopper
Copy link
Member

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 added a commit that referenced this issue Dec 8, 2024
This is accidental and possibly undesirable behaviour, but it seems
like an improvement to at least document the current behaviour.

Clarifies potentially confusing behaviour which resulted in the opening
of #606.
floehopper added a commit that referenced this issue Dec 8, 2024
This is accidental and possibly undesirable behaviour, but it seems
like an improvement to at least document the current behaviour.

Clarifies potentially confusing behaviour which resulted in the opening
of #606.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants