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

Mocking a call with empty keyword arguments fails its parameter validation #588

Open
herwinw opened this issue Nov 17, 2022 · 9 comments
Open
Assignees

Comments

@herwinw
Copy link
Contributor

herwinw commented Nov 17, 2022

Given the following code (a slight change from the example of with):

object = mock()
object.expects(:expected_method).with(:param1, :param2)
kwargs = {}
object.expected_method(:param1, :param2, **kwargs)

This worked fine in Mocha 1.x, but this results in a pretty weird error message with Mocha 2.0.2:

unexpected invocation: #<Mock:0x1630>.expected_method(:param1, :param2, )

When we explicitly add an empty hash to the expectation, we can make the test pass with a warning that tells us to change the code:

Mocha deprecation warning at example.rb:4:in `test_kwargs': Expectation defined at example:2:in `test_kwargs' expected positional hash ({}), but received keyword arguments (). These will stop matching when strict keyword argument matching is enabled. See the documentation for Mocha::Configuration#strict_keyword_argument_matching=.

With strict_keyword_argument_matching = true, both options fail

@floehopper floehopper self-assigned this Nov 17, 2022
@floehopper
Copy link
Member

@herwinw

Thanks for reporting this. I've managed to reproduce the issue under Ruby v2.7 but not under Ruby 3.1. Is that what you're seeing too?

My initial reaction is surprise that this test passed with Mocha v1.x since as soon as you call Expectation#with an invocation needs to match all the expected arguments (and/or matchers) passed in to Expectation#with. In your example with Mocha v1.x it seems as if Mocha is not including the empty keyword argument hash in that matching process which seems inconsistent with how I would expect it to work.

Did you find that surprising too or was it the behaviour you expected? If it was, then can you explain why?

@herwinw
Copy link
Contributor Author

herwinw commented Nov 17, 2022

This was indeed Ruby 2.7, I forgot to mention that.

Mocha 1.x on Ruby 2.7 has a few other quirks, the following lines are equivalent:

object.expects(:expected_method).with(:param1, :param2, foo: 1)
object.expects(:expected_method).with(:param1, :param2, { foo: 1 })

And for the caller too:

object.expected_method(:param1, :param2, foo: 1)
object.expected_method(:param1, :param2, { foo: 1 })

This is the default Ruby 2.x behaviour when using a single splat argument to gather both positional and keyword arguments, so I can guess why and how this worked in Mocha 1.x.

@floehopper
Copy link
Member

floehopper commented Nov 18, 2022

This was indeed Ruby 2.7, I forgot to mention that.

Thanks for confirming. And thanks for the extra info. I'm still interested in an answer to the question below if you have a chance:

In your example with Mocha v1.x it seems as if Mocha is not including the empty keyword argument hash in that matching process which seems inconsistent with how I would expect it to work.

Did you find that surprising too or was it the behaviour you expected? If it was, then can you explain why?

@herwinw
Copy link
Contributor Author

herwinw commented Nov 18, 2022

Did you find that surprising too or was it the behaviour you expected? If it was, then can you explain why?

That is what I tried to explain with the extra info. If you only use the single splat operator for your argument capture in Ruby 2, the keyword arguments are converted to a hash that is added to the positional keyword arguments:

irb(main):001:0> ruby2_keywords def foo(*a) = a.to_s
=> nil
irb(main):002:0> foo(1, 2)
=> "[1, 2]"
irb(main):003:0> foo(1, 2, a: 1)
=> "[1, 2, {:a=>1}]"
irb(main):004:0> foo(a: 1)
=> "[{:a=>1}]"

So I guess I looked at it the other way around: I can guess the implementation based on the behaviour, so the behaviour does not surprise me

@floehopper
Copy link
Member

@herwinw

That is what I tried to explain with the extra info.

Ah, I see what you mean now. However, rather than thinking about the implementation / the behaviour of Ruby and working backwards, I'm trying to think what would be least surprising to a user of Mocha.

Currently I think I'm leaning towards saying that the behaviour of Mocha v1.x in Ruby v2.7 is a bug, because it seems inconsistent / surprising to me. That would suggest releasing a patch version of v1.x to issue a deprecation warning and then another patch version of v1.x to "correct" the behaviour. It also assumes that there's a way to implement the deprecation warning and the correct behaviour - I haven't really thought much about that yet.

I think this would mean you would need to change the arguments or matchers in the call to Expectation#with in your test(s) to more explicitly expect the keyword arguments / final hash.

Does that make sense to you? Or am I missing something?

@herwinw
Copy link
Contributor Author

herwinw commented Nov 26, 2022

I guess that would make a backwards incompatible change, which should not happen in a minor release (according to semantic versioning).

Also: I'm not sure how well this change would work. The Gemspec for the latest 1.x release of Mocha says the minimal Ruby version is 1.9. Keyword arguments have kind of been a hacked on extension in Ruby for years, I expect this would break things.

My personal opinion: Don't change the behaviour of 1.x: it is too much effort and is very likely to break things. I'm not saying this is the best possible behaviour, but it's probably good enough given that nobody ever reported an issue for this behaviour.
The current 2.0.x release has issues when running with Ruby 2.7. I guess the best way of action is to either fix that, or bump the required/recommended Ruby version for the 2.x release to Ruby 3.0

@floehopper
Copy link
Member

@herwinw:

Sorry I haven't updated this issue in a while. I have been thinking about it, but haven't had much time to actually do anything!

A small improvement I could make fairly easily would be to change the way method invocations are represented - in particular so that empty keyword arguments are displayed more sensibly. This would at least mean the weird error message you originally reported would make a bit more sense!

Currently an invocation with keyword arguments vs a positional Hash are represented like this in error messages:

foo.bar(:x => 1) # keyword arguments
foo.bar({:x => 1}) # positional Hash

i.e. the only distinction is the presence or absence of surrounding braces. Using the hash-rocket syntax is necessary to cope with the potential for keyword arguments with String keys.

However, keyword arguments can be empty, so how about:

foo.bar(**{:x => 1}) # keyword arguments
foo.bar({:x => 1}) # positional Hash

Then empty keyword arguments would be represented like this:

foo.bar(**{}) # keyword arguments
foo.bar({}) # positional Hash

The latter seems a bit clunky, but at least it's very explicit. I suppose I could make empty keyword arguments an exception and just use the double-splat for that.

Thoughts?

@herwinw
Copy link
Contributor Author

herwinw commented Dec 25, 2022

I think only showing **{ when it's empty would be clearer, the foo.bar(**{:x => 1}) looks very complex, and it doesn't really add anything

@floehopper
Copy link
Member

I think only showing **{ when it's empty would be clearer, the foo.bar(**{:x => 1}) looks very complex, and it doesn't really add anything

I tend to agree. 👍

However, I'm also still not convinced the current approach of using the presence or absence of wrapping braces is a good way to distinguish a keyword Hash vs a standard Hash. And I think this might be even more true if we started using the Ruby v1.9 Hash format in Mocha::HashMethods#mocha_inspect. I'm going to give it some more thought.

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