rspec / rspec-mocks

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

Fix argument forwarding for "receive" with Ruby 3.2.0 #1514

Closed benoittgt closed 1 year ago

benoittgt commented 1 year ago

It seems there is place where arguments forwarding is not ok.

I continued the work started by @pirj on https://github.com/rspec/rspec-mocks/pull/1497

fixes #1497 fixes #1502 fixes #1495 fixes #1513 fixes #1512

Related:

benoittgt commented 1 year ago

CI failures seems to be unrelated. I will look at them separately on rspec-support. Probably https://github.com/rspec/rspec-support/pull/553 and https://github.com/rspec/rspec-support/pull/555

paddor commented 1 year ago

This branch fixes my test suite on Ruby 3.2. 👍

pirj commented 1 year ago

I dared pushing a commit that makes the kwargs vs positional options hash check more strict.

TODO:

pirj commented 1 year ago

@paddor May I kindly ask you to repeat your test after the last commit?

pirj commented 1 year ago

https://github.com/rspec/rspec-core/pull/2993 is needed to make the CI green(er) for rspec-core sub-build.

paddor commented 1 year ago

@pirj It mostly passes. Some #receive_message_chain-lines with kwargs fail:

#<Double (anonymous)> received unexpected message :store_event with ({:message=>"core.system_events.force_variables", :object=>{:note=>nil, :release_at=>nil, :variable=>{:code=>nil, :desc_i18n=>nil, :label=>nil, :label_i18n=>nil}}, :username=>nil})

Note that the call-site correctly specified these arguments as keywords, not as a Hash.

If I refactor them to use #receive with intermediate spy-objects, the whole testsuite passes.

eregon commented 1 year ago

@pirj I think it would be safer to ~revert~ remove that strict check (i.e., stricter than Ruby itself is), see https://github.com/rspec/rspec-mocks/pull/1514#discussion_r1060563272. Otherwise it sounds like a huge backward-incompatible change for something which doesn't really gain anything.

pirj commented 1 year ago

@eregon The check is certainly stricter now for def foo(options = {}) or def foo(options).

But it's not stricter than Ruby's for def foo(**options) and def foo(bar:).

To make a distinction between those two cases, we would have to make ArgumentListMatcher aware of the method signature.

All those cases are different instance.method(:foo).parameters:

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. https://github.com/rspec/rspec-support/pull/537

rickychilcott commented 1 year ago

Running this branch fixes all of my mocking issues. Thank you!

This is more of an rspec-rails issue, but I'm noticing a have_enqueued_job matcher is no longer finding the job.

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

Mange commented 1 year ago

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 ``` Randomized with seed 60000 example Called foo(a: 1) finds no call on single call (FAILED - 1) Called foo(a: 1) Called foo(a: 2) finds other call on two calls, but still fails (FAILED - 2) Failures: 1) example finds no call on single call 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 # ./spec/broken_spec.rb:20:in `block (2 levels) in ' 2) example finds other call on two calls, but still fails Failure/Error: expect(Ex).to have_received(:foo).with(a: 1) # received :foo with unexpected arguments expected: ({:a=>1}) (options hash) got: ({:a=>2}) (keyword arguments) Diff: @@ -1 +1 @@ -[{:a=>1}] +[{:a=>2}] # ./spec/broken_spec.rb:36:in `block (2 levels) in ' Finished in 0.01183 seconds (files took 0.26323 seconds to load) 2 examples, 2 failures Failed examples: rspec ./spec/broken_spec.rb:11 # example finds no call on single call rspec ./spec/broken_spec.rb:23 # example finds other call on two calls, but still fails ```
Gem versions (commits) ``` GIT remote: https://github.com/rspec/rspec-core.git revision: 522b7727d02d9648c090b56fa68bbdc18a21c04d branch: main specs: rspec-core (3.13.0.pre) rspec-support (= 3.13.0.pre) GIT remote: https://github.com/rspec/rspec-expectations.git revision: b1fa2e620b03ed05c737db10b2727a0706eca7d3 branch: main specs: rspec-expectations (3.13.0.pre) diff-lcs (>= 1.2.0, < 2.0) rspec-support (= 3.13.0.pre) GIT remote: https://github.com/rspec/rspec-mocks.git revision: d8a213c26c31bc940f5060145b85131cbcf43715 branch: ruby-3.2 specs: rspec-mocks (3.13.0.pre) diff-lcs (>= 1.2.0, < 2.0) rspec-support (= 3.13.0.pre) GIT remote: https://github.com/rspec/rspec-rails.git revision: c60ff7907559653cd9d1ec1a6113bf86c9359fab branch: main specs: rspec-rails (6.1.0.pre) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) rspec-core (= 3.13.0.pre) rspec-expectations (= 3.13.0.pre) rspec-mocks (= 3.13.0.pre) rspec-support (= 3.13.0.pre) ```
paddor commented 1 year ago

Now it works with and without the refactorings in my testsuite. 👍

pirj commented 1 year ago

@paddor Just to make sure, do your receive_message_chain work, too?

pirj commented 1 year ago

@JonRowe I intend to:

JonRowe commented 1 year ago

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!

JonRowe commented 1 year ago

Released in 3.12.2

JonRowe commented 1 year ago

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