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

Message expectation with block arguments for keyword argument method behaves differently #1486

Open TonyCTHsu opened 1 year ago

TonyCTHsu commented 1 year ago

Subject of the issue

Message expectation with block arguments for keyword argument method behaves differently for ruby 3.2.0preview2.

The code snippets works fine with ruby 3.0.3 and ruby 3.1.1. I wasn't sure what is the root cause.

Your environment

Steps to reproduce

RSpec.describe do
  class TestObject
    def initialize(**kwargs)
    end
  end
  it 'double splat block args' do
    expect(TestObject).to receive(:new) do |**opts|
      expect(opts).to eq(foo: 'bar')
    end

    hash = { foo: 'bar' }
    TestObject.new(**hash)
  end

  it 'keyword block args' do
    expect(TestObject).to receive(:new) do |foo:, **_|
      expect(foo).to eq('bar')
    end

    hash = { foo: 'bar' }
    TestObject.new(**hash)
  end
end

Expected behavior

The test passes like

Actual behavior

Failures:

  1) double splat block args
     Failure/Error: expect(opts).to eq(foo: 'bar')

       expected: {:foo=>"bar"}
            got: {}

       (compared using ==)

       Diff:
       @@ -1,2 +1 @@
       -:foo => "bar",
  2) keyword block args
     Failure/Error:
       expect(TestObject).to receive(:new) do |foo:, **_|
         expect(foo).to eq('bar')
       end

     ArgumentError:
       missing keyword: :foo
pirj commented 1 year ago

Does it work the same way with other methods, not initialize/new?

JonRowe commented 1 year ago

Interestingly I tried this on Mac (ruby 3.2.0preview2 (2022-09-09 master 35cfc9a3bb) [arm64-darwin21]) and it passed, so I'm inclined to close this for now as a Ruby bug especially as its not an expected change in behaviour...

ivoanjo commented 1 year ago

Hey @pirj and @JonRowe thanks for the feedback! I work with @TonyCTHsu and he's off for a few weeks, so I can jump in and provide the extra information.

Does it work the same way with other methods, not initialize/new?

Yes! See my provided extended example below.

Interestingly I tried this on Mac (ruby 3.2.0preview2 (2022-09-09 master 35cfc9a3bb) [arm64-darwin21]) and it passed, so I'm inclined to close this for now as a Ruby bug especially as its not an expected change in behaviour...

I suspect it may be a deliberate incompatible Ruby change, as the 3.2.0-preview2 release notes mention it https://www.ruby-lang.org/en/news/2022/09/09/ruby-3-2-0-preview2-released/ ("Methods taking a rest parameter (like args) and wishing to delegate keyword arguments through foo(args) must now be marked with ruby2_keywords (if not already the case). [...]").

You are right re: the reproduction, I remembered Tony showing me the issue and when I just re-ran it locally I was actually not seeing it trigger.

It turns out the reproduction example is incomplete. In particular, mocks.verify_partial_doubles = true needs to be set in the configuration to trigger the issue -- without it, the issue does not trigger.

Here is an extended example that is self-contained and reproduces on my machine. I can reproduce the issue with:

so it seems to affect every release at least >= 3.2.0-preview2.

Full reproduction:

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'

  gem 'rspec', '= 3.11.0'
end

require 'rspec/autorun'

puts RUBY_DESCRIPTION

RSpec.configure do |config|
  config.mock_with :rspec do |mocks|
    mocks.verify_partial_doubles = true # changing this to false makes the issue go away
  end
end

RSpec.describe do
  class TestObject
    def initialize(**kwargs)
    end

    def foo(**kwargs)
    end
  end

  context 'TestObject.new' do
    it 'double splat block args' do
      expect(TestObject).to receive(:new) do |**opts|
        expect(opts).to eq(foo: 'bar')
      end

      hash = { foo: 'bar' }
      TestObject.new(**hash)
    end

    it 'keyword block args' do
      expect(TestObject).to receive(:new) do |foo:, **_|
        expect(foo).to eq('bar')
      end

      hash = { foo: 'bar' }
      TestObject.new(**hash)
    end
  end

  context 'TestObject#foo' do
    let(:instance) { TestObject.new }

    it 'double splat block args' do
      expect(instance).to receive(:foo) do |**opts|
        expect(opts).to eq(foo: 'bar')
      end

      hash = { foo: 'bar' }
      instance.foo(**hash)
    end

    it 'keyword block args' do
      expect(instance).to receive(:foo) do |foo:, **_|
        expect(foo).to eq('bar')
      end

      hash = { foo: 'bar' }
      instance.foo(**hash)
    end
  end
end