freerange / mocha

A mocking and stubbing library for Ruby

Home Page:https://mocha.jamesmead.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Mocking a call with empty keyword arguments fails its parameter validation

herwinw opened this issue · comments

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

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

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.

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?

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

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

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

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

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

#651 & #652 may be relevant here