-
-
Notifications
You must be signed in to change notification settings - Fork 358
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
Fix argument forwarding for "receive" with Ruby 3.2.0 #1514
Conversation
CI failures seems to be unrelated. I will look at them separately on |
expect(dbl).to receive(:kw_args_method).with({a: 1, b: 2}) | ||
dbl.kw_args_method(a: 1, b: 2) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should fail, shouldn't it? We expect an options hash, and pass kwargs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. I am wondering if it is a side effect of changes in 3.2. Like on release note. @eregon any idea?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We seem to be in the opposite situation when an option hash passed as a positional argument to an expectation suddenly matches with a kwargs hash.
I suppose that the problem is here:
if Hash === expected_args.last && Hash === actual_args.last
if !Hash.ruby2_keywords_hash?(actual_args.last) && Hash.ruby2_keywords_hash?(expected_args.last)
return false
In our scenario, Hash.ruby2_keywords_hash?(actual_args.last)
is true
, while the other one is false
.
They don't match, but we don't return false
like we do e.g. here:
if Hash === expected_hash && Hash === actual_hash &&
(Hash.ruby2_keywords_hash?(expected_hash) != Hash.ruby2_keywords_hash?(actual_hash))
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest changing this:
- if !Hash.ruby2_keywords_hash?(actual_args.last) && Hash.ruby2_keywords_hash?(expected_args.last)
+ if Hash.ruby2_keywords_hash?(actual_args.last) != Hash.ruby2_keywords_hash?(expected_args.last)
This is causing two more specs to fail, at least on Ruby 3.2:
it "matches against a hash submitted as keyword arguments and received as positional argument (in both Ruby 2 and Ruby 3)" do
expect(dbl).to receive(:kw_args_method).with(1, {:required_arg => 2, :optional_arg => 3})
dbl.kw_args_method(1, :required_arg => 2, :optional_arg => 3)
end
it "matches against a hash submitted as keyword arguments a and received as a positional argument (in both Ruby 2 and Ruby 3)" do
opts = {:a => "a", :b => "b"}
expect(a_double).to receive(:random_call).with(opts)
a_double.random_call(:a => "a", :b => "b")
end
But if we'd make this comparison strict, I agree that it should fail.
Do you guys agree to make such a change, and see if it affects other Rubies?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The failure message is suboptimal:
received unexpected message :kw_args_method with ({:a=>1, :b=>2})
while the method was indeed called, and arguments were alike, it's just the Hash.ruby2_keywords_hash?
that didn't match.
We do it better here:
" expected: (#{expected_input.inspect}, {:one=>1}) (keyword arguments)\n" \ │
" got: (#{actual_input.inspect}, {:one=>1}) (options hash)\n" \
but this differ doesn't seem to come into play.
Might be related: rspec/rspec-support#537
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The problem is the comment above:
# if both arguments end with Hashes, and if one is a keyword hash and the other is not, they don't match
is super confusing. That should be updated to something like:
# if both arguments end with Hashes, and if #with was called with keywords but the method was called without keywords, they don't match
# if #with is called without keywords, e.g., with({a: 1}), then it's fine to call it with either foo(a: 1) or foo({a: 1}) because it's as if the method was: def foo(options).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But this is only true for methods that accept positional parameters, right? E.g. for:
class A
def foo(bar:)
end
end
a = A.new
expect(a).to receive(:foo).with({bar: 1})
a.foo({bar: 1})
If we follow
if
#with
is called without keywords, e.g.,with({a: 1})
, then it's fine to call it with eitherfoo(a: 1)
orfoo({a: 1})
because it's as if the method was:def foo(options)
.
The spec will pass, but the real-life code:
a = A.new
a.foo({bar: 1})
would blow up with:
ArgumentError: wrong number of arguments (given 1, expected 0; required keyword: bar)
that means we would be concealing the error.
Is my understanding correct, @eregon ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a mock, so the method might not exist, right?
From with({a: 1})
that doesn't indicate the method takes keyword arguments, hence it can be called with whatever (passing a: 1
or {a: 1}
shouldn't make any difference).
If the method does exist like in that case, I think maybe RSpec could warn that you call with
without kwargs when the method accepts kwargs.
Or, you could actually check whether the existing method accepts kwargs or not for the kwargs-vs-positional check, but then what happens if the method does not exist?
Wouldn't this specific example fail anyway because it actually calls the original method?
And if it doesn't call the original method, isn't it wrong to look at the original method?
EDIT: it indeed does not call the original for expect(a).to receive(:foo).with({bar: 1})
.
That's super confusing to me, it just returns nil
instead.
MSpec would always call the original, unless one uses .and_return
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also this example wouldn't be caught by your change, right? (no ruby2_keyword flag on both sides)
So it's a bug of writing .with({bar: 1})
which means "expect no kwargs" when the real method only accepts kwargs. Hence maybe that's worth a warning.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this example wouldn't be caught by your change
Fair enough. The rest makes total sense, too.
This branch fixes my test suite on Ruby 3.2. 👍 |
I dared pushing a commit that makes the kwargs vs positional options hash check more strict. TODO:
|
@paddor May I kindly ask you to repeat your test after the last commit? |
rspec/rspec-core#2993 is needed to make the CI green(er) for |
@pirj It mostly passes. Some
Note that the call-site correctly specified these arguments as keywords, not as a Hash. If I refactor them to use |
@pirj I think it would be safer to |
@eregon The check is certainly stricter now for But it's not stricter than Ruby's for To make a distinction between those two cases, we would have to make All those cases are different Class.new { def foo(options); end }.new.method(:foo).parameters # => [[:req, :options]]
Class.new { def foo(options = {}); end }.new.method(:foo).parameters # => [[:opt, :options]]
Class.new { def foo(**options); end }.new.method(:foo).parameters # => [[:keyrest, :options]]
Class.new { def foo(bar:); end }.new.method(:foo).parameters # => [[:keyreq, :bar]] What is making matter worse is that Ruby was doing weird things with extracting some arguments from the options hash, see e.g. rspec/rspec-support#537 |
Running this branch fixes all of my mocking issues. Thank you! This is more of an rspec-rails issue, but I'm noticing a expect {
get :create, params: {onboarding_signup_id: onboarding_signup_id}
}.to have_enqueued_job(Sendgrid::ListSignupJob).with(
email: user.email, list: OnboardingSignupsController::CURRENT_ONBOARDING_LIST_NAME
)
expected to enqueue exactly 1 jobs, with [{:email=>"clair_mcdermott@example.net", :list=>"PROD Onboarding v1"}], but enqueued 0
Queued jobs:
Sendgrid::ListSignupJob job with [{:email=>"clair_mcdermott@example.net", :list=>"PROD Onboarding v1"}], on queue default ^^^ passes in Ruby 3.1 |
I've tested this branch but expectations on any keyword argument seem to not work. require "spec_helper"
class Ex
def self.foo(a:)
puts "Called foo(a: #{a.inspect})"
true
end
end
RSpec.describe "example" do
it "finds no call on single call" do
allow(Ex).to receive(:foo).and_call_original
Ex.foo(a: 1)
# Failure/Error: expect(Ex).to have_received(:foo).with(a: 1)
# (Ex (class)).foo({:a=>1})
# expected: 1 time with arguments: ({:a=>1})
# received: 0 times
expect(Ex).to have_received(:foo).with(a: 1)
end
it "finds other call on two calls, but still fails" do
allow(Ex).to receive(:foo).and_call_original
Ex.foo(a: 1)
Ex.foo(a: 2)
# #<Ex (class)> received :foo with unexpected arguments
# expected: ({:a=>1}) (options hash)
# got: ({:a=>2}) (keyword arguments)
# Diff:
# @@ -1 +1 @@
# -[{:a=>1}]
# +[{:a=>2}]
expect(Ex).to have_received(:foo).with(a: 1)
expect(Ex).to have_received(:foo).with(a: 2)
# Note that just expecting the second call will still fail, but with an
# inverted error message; it will show the first call.
end
end Output
Gem versions (commits)
|
Now it works with and without the refactorings in my testsuite. 👍 |
ef1aab0
to
06bf72f
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good to me
@paddor Just to make sure, do your |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good!
@JonRowe I intend to:
|
Thanks for tackling this, would have been nice to wait for my review but as this ended up as a simple one line lib change it's ok, good work on the updated comment too! |
Released in 3.12.2 |
I added the 3.2 builds myself coordinating with 3-12-maintenance, with a temporary suppression of the broken spec, I still think it can be fixed rather than skipped like this. Also I've now disabled rebase merges on our repos as they've been used accidentally again and it makes cherry picking to maintenance branches a nightmare, we want single commits for PRs (merge or squash only). |
It seems there is place where arguments forwarding is not ok.
I continued the work started by @pirj on #1497
fixes #1497
fixes #1502
fixes #1495
fixes #1513
fixes #1512
Related: