rspec / rspec-mocks

RSpec's 'test double' framework, with support for stubbing and mocking
https://rspec.info
MIT License
1.16k stars 358 forks source link

Explicit keyword argument constraints #1408

Closed Drenmi closed 3 years ago

Drenmi commented 3 years ago

Description

Since Ruby has now taken the stance that keyword arguments and hashes are not interchangeable, we're left with a class of errors that can be easily masked when stubbing a method call.

Consider this method definition, representing a common pattern for, let's say, API wrappers:

module Foo
  def self.bar(baz:)
    ...
  end
end

Now, in the test for another class, Qux, which uses this wrapper as a collaborator, we stub out the call:

before do
  allow(Foo).to receive(bar).and_return(mock_api_response)
end

So far, so good. Now assume in Qux, we are doing this:

def fetch_bar
  bar_options = {
    ...
  }

  Foo.bar(bar_options)
end

Notice we have forgotten to use ** to convert the options into keyword arguments. This will result in a warning in Ruby 2.7, and a runtime error in Ruby 3.

However, our tests will pass. Effectively hiding this mistake.

We can try to add a constraint in our test:

before do
  allow(Foo).to receive(bar).with(option_one: anything, option_two: anything).and_return(mock_api_response)
end

... but to no avail. The tests still pass.

Possible solution

Of course the first solution that comes to mind is to add a new argument matcher here, e.g. keyword_args.

allow(Foo).to receive(bar).with(keyword_args(...)).and_return(mock_api_response)

Although the implementation of this is not trivial, as Ruby, despite now making a clear distinction between keyword arguments and hashes, does not expose the former as its own object.

Any solution of this sort would likely need to involve some runtime introspection using Binding, or similar.

Your environment

Expected behavior

The test suite catches any runtime errors that might occur.

Actual behavior

The test suite passes.

JonRowe commented 3 years ago

Although the implementation of this is not trivial, as Ruby, despite now making a clear distinction between keyword arguments and hashes, does not expose the former as its own object.

Ruby doesn't tell you what a keyword hash is, but you can detect all of the circumstances when keywords can be used, because the method itself contains that information. We use this information in the method signature verifier, and we have some logic to detect when you are passing keyword arguments, its just not perfect in Ruby 3 world. Its also possible to detect keyword hashes when used with ruby2 keywords, which is the approach #1394 uses which I think would solve your use case.

Drenmi commented 3 years ago

Thanks, @JonRowe!

Based on the PR description, this is indeed what we were looking for. 🙏