Skip to content
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 stubbing prepended only methods #1218

Merged
merged 1 commit into from
Apr 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Bug Fixes:

* Support keyword argument semantics when constraining argument expectations using
`with` on Ruby 3.0+ (Yusuke Endoh, #1394)
* Fix stubbing of prepended-only methods. (Lin Jen-Shin, #1218)

### 3.10.2 / 2021-01-27
[Full Changelog](http://github.com/rspec/rspec-mocks/compare/v3.10.1...v3.10.2)
Expand Down
7 changes: 6 additions & 1 deletion lib/rspec/mocks/instance_method_stasher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ def method_owned_by_klass?
# `#<MyClass:0x007fbb94e3cd10>`, rather than the expected `MyClass`.
owner = owner.class unless Module === owner

owner == @klass || !(method_defined_on_klass?(owner))
owner == @klass ||
# When `extend self` is used, and not under `allow_any_instance_of`
# nor `expect_any_instance_of`.
(owner.singleton_class == @klass &&
godfat marked this conversation as resolved.
Show resolved Hide resolved
pirj marked this conversation as resolved.
Show resolved Hide resolved
!Mocks.space.any_instance_recorder_for(owner, true)) ||
!(method_defined_on_klass?(owner))
end
end
end
Expand Down
18 changes: 12 additions & 6 deletions lib/rspec/mocks/method_double.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,11 @@ def restore_original_method
return unless @method_is_proxied

remove_method_from_definition_target
@method_stasher.restore if @method_stasher.method_is_stashed?
restore_original_visibility

if @method_stasher.method_is_stashed?
@method_stasher.restore
restore_original_visibility
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, we could remove all the checks in restore_original_visibility when this is only called when @method_stasher.method_is_stashed?

end

@method_is_proxied = false
end
Expand All @@ -104,10 +107,7 @@ def show_frozen_warning

# @private
def restore_original_visibility
return unless @original_visibility &&
MethodReference.method_defined_at_any_visibility?(object_singleton_class, @method_name)

object_singleton_class.__send__(@original_visibility, method_name)
method_owner.__send__(@original_visibility, @method_name)
godfat marked this conversation as resolved.
Show resolved Hide resolved
end

# @private
Expand Down Expand Up @@ -249,6 +249,12 @@ def new_rspec_prepended_module
end
end

def method_owner
@method_owner ||=
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it affect the performance?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's memoized so I don't think we'll see anything noticeable in practice. If we disregard memoization and run simple benchmark, object.method(method_name).owner is about 3 times faster:

Warming up --------------------------------------
 object.method.owner   261.517k i/100ms
       bind and call    91.219k i/100ms
Calculating -------------------------------------
 object.method.owner      2.661M (± 0.9%) i/s -     13.337M in   5.011938s
       bind and call    914.184k (± 1.0%) i/s -      4.652M in   5.089434s

From running this:

require 'benchmark/ips'

Benchmark.ips do |x|
  object = Object.new

  x.report('object.method.owner') do
    object.method(:to_s).owner
  end

  x.report('bind and call') do
    Object.instance_method(:method).bind(object).call(:to_s).owner
  end
end

Well, or we can remove the test and calling out that do not override method, which I fully support :P

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm clearly on your side with you that one shouldn't override Kernel's internals with unrelated application-level implementations. However, someone's code may break with a weird message, and they will open a ticket, and we'll have to address it, since even though this might not be a good style to override method, it's not our call to enforce that in any way.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, of course, that's why I was half joking there :)

Half joking, half serious, because there are a lot of other methods we're relying on, and of course we won't want to be so defensive to do it this way for most of the built-in methods? (or a rule of thumb is, whenever someone is filing a ticket 🤷 This is also relying on someone isn't trying to break RSpec intentionally)

I see method or send is more commonly overridden with a completely different behaviour (if it's the same behaviour like some people will override method_missing also with respond_to_missing? properly, that would be mostly alright, but issues might still happen especially for mocks implementation which often talks about proxies), and that's why we have __send__ we can use, sadly not for method and it may be confusing that __method__ is actually a completely different method! Not like __send__ is an alias of send.

Sorry that this is probably falling down to a rant. To be more on the topic, since the test is already there, I don't think a regression is acceptable. However if we never added that test, I would think it's fine to just say "don't override method". Maybe RSpec 5, or maybe it doesn't matter after all. It's just one method right now. We don't have an army of Object.instance_method(method_name).bind(object) yet. Or do we? Maybe it's inside one of the support libraries already? 😅 To be honest I won't be surprised!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We defend against a number of quite odd things:

# This class protects us against Mutex.new stubbed out within tests

rspec/rspec-core#2886, rspec/rspec-support#431 and in a few other places.

Maybe it's inside one of the support libraries already? 😅

Good point, it is! 😄
https://github.com/rspec/rspec-support/blob/d63133f478408c1d965e673b96ad10ef5a5d183f/lib/rspec/support.rb#L53

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We defend against a number of quite odd things:

Oh my, I was guilty here. I am sure I was once lazy and stubbed File.read without explicit about the arguments, and it caused some issues. However being explicit about a specific argument did the trick for me.

I wonder if this kinds of things will need a more general approach. For example, will this work?

RSpec::Support::Source::File = ::File.dup

Good point, it is! 😄

Ok, thank you for spotting that. I updated the code to use it instead.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kinds of things

I can think of three cases we've touched:

  1. Ruby core method stubbed (File.read, File.expand_path, Mutex.new)
  2. Ruby core method overridden in a subclass (Kernel.method)
  3. Attempt to define a method with memoized helpers that would break Example (to_s, initialize)

Not sure if there could be a common solution to all of this.

Copy link
Author

@godfat godfat Apr 27, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ruby core method stubbed (File.read, File.expand_path, Mutex.new)

I just tried this does work:

PreservedFile = File.dup

def File.read
  'break you'
end

PreservedFile.read('README.md')

We can have a list of preserved classes.

module RSpec
  module Preserved
    File = ::File.dup
    Mutex = ::Mutex.dup
  end
end

Ruby core method overridden in a subclass (Kernel.method)

We can always do it via the same trick.

module RSpec
  module Preserved
    module_function
    def send_kernel(object, message, *args, **kargs)
      ::Kernel.instance_method(message).bind(object).call(*args, **kargs)
    end
  end
end

method_owner = RSpec::Preserved.send_kernel(object, :method).call(method_name).owner

Edited: Fixed above send_kernel usage.

Attempt to define a method with memoized helpers that would break Example (to_s, initialize)

For to_s and initialize I think we should just raise an error like now, indeed. It would be too much work for those to be overridden. Even if we can stick with __to_s__ or __initialize__ and play around with allocate, it's easily broken with other integration.

Not sure if there could be a common solution to all of this.

I didn't mean a common solution to all of above. I meant a common solution for the same issue. For example, we use the same way to resolve File.read or Mutex.new (a module method) got overridden. We use the same way to resolve a particular instance method got overridden.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our policy is to do the method work around for cases as they occur

# We do this because object.method might be overridden.
::RSpec::Support.method_handle_for(object, @method_name).owner
end

def remove_method_from_definition_target
# In Ruby 2.4 and earlier, `remove_method` is private
definition_target.__send__(:remove_method, @method_name)
Expand Down
28 changes: 28 additions & 0 deletions spec/rspec/mocks/stub_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ module ToBePrepended
def value
"#{super}_prepended".to_sym
end

def value_without_super
:prepended
end
end

it "handles stubbing prepended methods" do
Expand Down Expand Up @@ -165,6 +169,15 @@ def object.value; :original; end
expect(object.value).to eq :stubbed
end

it "handles stubbing prepending methods that were only defined on the prepended module" do
object = Object.new
object.singleton_class.send(:prepend, ToBePrepended)

expect(object.value_without_super).to eq :prepended
allow(object).to receive(:value_without_super) { :stubbed }
expect(object.value_without_super).to eq :stubbed
end

it 'does not unnecessarily prepend a module when the prepended module does not override the stubbed method' do
object = Object.new
def object.value; :original; end
Expand Down Expand Up @@ -350,6 +363,21 @@ class << self; public :hello; end;
expect(mod.hello).to eq(:hello)
end

it "correctly restores from allow_any_instance_of for self extend" do
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test can catch the need for !Mocks.space.any_instance_recorder_for(owner, true) in the other condition. It was discovered in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44417#note_429658296 thanks to @engwan !

mod = Module.new {
extend self
def hello; :hello; end
}

allow_any_instance_of(mod).to receive(:hello) { :stub }

expect(mod.hello).to eq(:stub)

reset_all
godfat marked this conversation as resolved.
Show resolved Hide resolved

expect(mod.hello).to eq(:hello)
end

it "correctly handles stubbing inherited mixed in class methods" do
mod = Module.new do
def method_a
Expand Down