rspec-expectations icon indicating copy to clipboard operation
rspec-expectations copied to clipboard

`NoMethodError` when `receive` and `have_receive` are aliased with `alias_matcher`. Conflicts with ActiveSupport's `Object#with`

Open pucinsk opened this issue 10 months ago • 3 comments

Subject of the issue

Rails 7.1 added Object#with which conflicts with receive and have_received that define a #with method (chain :with) when used via an alias_matcher.

Related issue: https://github.com/rspec/rspec-expectations/issues/1437 (solved)

Your environment

  • Ruby version: 3.2.2
  • rspec-expectations version: 3.13.0
  • Rails version: 7.1.3.2

Steps to reproduce

require "rails_helper"

RSpec::Matchers.alias_matcher :have_received_alias, :have_received
RSpec::Matchers.alias_matcher :receive_alias, :receive

RSpec.describe "Foo" do
  it do
    expect(1).to have_received_alias(:foo_bar).with(foo: :bar)
  end

  it do
    expect(1).to receive_alias(:foo_bar).with(foo: :bar)
  end
end

Expected behavior

Neither assert should raise an NoMethodError

Actual behavior

Arguments passed for with method are treated as Object methods.

Failures:

  1) Foo 
     Failure/Error: expect(1).to receive_alias(:foo_bar).with(foo: :bar)
     
     NoMethodError:
       undefined method `foo' for #<RSpec::Mocks::Matchers::Receive:0x00000001107d1868>
     # ./spec/dummy_spec.rb:14:in `block (2 levels) in <top (required)>'
     # ./spec/rails_helper.rb:233:in `block (2 levels) in <top (required)>'
     # ./spec/spec_helper.rb:147:in `block (2 levels) in <top (required)>'

  2) Foo 
     Failure/Error: expect(1).to have_received_alias(:foo_bar).with(foo: :bar)
     
     NoMethodError:
       undefined method `foo' for #<RSpec::Mocks::Matchers::HaveReceived:0x0000000128478b68 @method_name=:foo_bar, @block=nil, @constraints=[], @subject=nil>
     # ./spec/dummy_spec.rb:10:in `block (2 levels) in <top (required)>'
     # ./spec/rails_helper.rb:233:in `block (2 levels) in <top (required)>'
     # ./spec/spec_helper.rb:147:in `block (2 levels) in <top (required)>'

Finished in 2.84 seconds (files took 10.64 seconds to load)
2 examples, 2 failures

pucinsk avatar Apr 08 '24 07:04 pucinsk

I’m away from my computer. Wondering if ‘receive(:foo_bar)’ and ‘receive_alias(:foo_bar)’ would return instances of the same class? Why would there be such a difference?

pirj avatar Apr 08 '24 18:04 pirj

It seems that something is wrong with RSpec::Matchers::AliasedMatcher matcher. ActiveSupport seems to override HaveReceived methods.

I'm pasting some output from debugging session what I found, it might be helpful.

[1] pry(#<RSpec::ExampleGroups::Foo>)> have_received_alias(:foo_bar).class
=> RSpec::Matchers::AliasedMatcher
[2] pry(#<RSpec::ExampleGroups::Foo>)> have_received_alias(:foo_bar).class.superclass
=> RSpec::Matchers::MatcherDelegator
[3] pry(#<RSpec::ExampleGroups::Foo>)> have_received(:foo_bar).class
=> RSpec::Mocks::Matchers::HaveReceived
[4] pry(#<RSpec::ExampleGroups::Foo>)> have_received(:foo_bar).class.superclass
=> Object
...
[7] pry(#<RSpec::ExampleGroups::Foo>)> have_received_alias(:foo_bar).inspect
=> "#<RSpec::Mocks::Matchers::HaveReceived:0x0000000162438768 @method_name=:foo_bar, @block=nil, @constraints=[], @subject=nil>"

[8] pry(#<RSpec::ExampleGroups::Foo>)> have_received(:foo_bar).inspect
=> "#<RSpec::Mocks::Matchers::HaveReceived:0x00000001624348e8 @method_name=:foo_bar, @block=nil, @constraints=[], @subject=nil>"

[6] pry(#<RSpec::ExampleGroups::Foo>)> show-method have_received_alias(:foo_bar).with

From: gems/ruby/3.2.0/gems/activesupport-7.1.3.2/lib/active_support/core_ext/object/with.rb:4:

[5] pry(#<RSpec::ExampleGroups::Foo>)> show-method have_received(:foo_bar).with
From: gems/ruby/3.2.0/gems/rspec-mocks-3.13.0/lib/rspec/mocks/matchers/have_received.rb:53:

My current workaround:

class RailsAliasedMatcherPatched < RSpec::Matchers::AliasedMatcherWithOperatorSupport
  def with(*, **)
    base_matcher.__send__(:with, *, **)
  end
end

RSpec::Matchers.alias_matcher :have_received_alias, :have_received, klass: RailsAliasedMatcherPatched
RSpec::Matchers.alias_matcher :receive_alias, :receive, klass: RailsAliasedMatcherPatched

pucinsk avatar Apr 09 '24 11:04 pucinsk

I'm curious, what runs first in your setup, the Rails' patch to Object that defines with, or ours BaseDelegator that relies on the Object to be already patched. Wondering if this can be load order-dependent.

I could not reproduce the issue with this (similar to https://github.com/rails/rails/issues/49958):

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"
  git_source(:github) { |repo| "https://github.com/#{repo}.git" }
  gem "sqlite3"
  gem 'rails', '~> 7.1'
  gem 'rspec-rails'
end

require "rails/all"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database:
":memory:")

require 'rspec/rails'
require 'rspec/autorun'

RSpec::Matchers.alias_matcher :receive_alias, :receive

RSpec.describe 'alias_matcher' do
  it 'works' do
    d = double
    expect(d).to receive(:foo).with(1)
    d.foo(1)
  end

  it 'does not' do
    d = double
    expect(d).to receive_alias(:foo).with(1)
    d.foo(1)
  end
end

pirj avatar Apr 11 '24 00:04 pirj