-
-
Notifications
You must be signed in to change notification settings - Fork 357
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
Mocked singleton methods are bound to the mocked class and thus don't respect inheritance #1452
Comments
The issue seems to stem from the fact that This would likely need to be passed along to our This raises some behavioral considerations though. If we mock We have the option here -- to assert that we're really messaging If we think of it as duplicating the |
We can see a simple implementation of a working example by monkeypatching a couple things. require 'rspec/mocks'
module RSpec
module Mocks
@__method_double_receivers = {}
class MethodDouble
def proxy_method_invoked(receiver, *args, &block)
(RSpec::Mocks.instance_variable_get(:@__method_double_receivers)[original_method.to_s] ||= []).push(receiver)
@proxy.message_received method_name, *args, &block
end
end
class AndWrapOriginalImplementation
def call(*args, &block)
receiver = (RSpec::Mocks.instance_variable_get(:@__method_double_receivers)[@method.to_s] ||= []).pop
@block.call(@method.unbind.bind(receiver), *args, &block)
end
end
end
end Our previously failing spec now passes. Obviously this isn't the solution, and just tests the hypothesis. The implementation would need to take into account which behavioral model that makes the most sense to implement. |
Thanks for reporting, reproduction example, in-depth analysis and a patch. |
Thanks @pirj, it's not a patch by any means so no? I'm providing that as an example of how I personally tested the hypothesis, and so anyone else can as well -- since it makes mocking things like I would also imagine someone with a deeper understanding of the library should probably think on, if, and how it should be implemented, and based on my previous issue being closed before being understood, I'd need some level of consensus before personally spending time on it. Diving into the source though, it's obvious that these are largely understood concerns. There's a missing spec, which is really the first one I provide, and where we should start. Let's assume we add that spec and then consider, how do we want to make that pass? RSpec.describe "mocking with inheritance concerns" do
it "allows mocking a class method without breaking the use of `self`" do
allow(A).to receive(:foo).and_wrap_original { |method| "#{method.call}(mocked)" }
expect(Base.foo).to eq "Base.foo"
expect(A.foo).to eq "A.foo(mocked)"
expect(B.foo).to eq "B.foo(mocked)" # this fails
end
end What should the last expectation in the spec be? # Should it be considered a call _through_ A.foo?
expect(B.foo).to eq "B.foo(mocked)"
# OR should calling B.foo behave like a call to Base.foo directly?
expect(B.foo).to eq "B.foo"
# OR should mocking A simply break the reference to `self` in subclasses?
expect(B.foo).to eq "A.foo(mocked)" |
Your spec reflects how I would expect it to work intuitively. |
Thank you for this issue, its much clearer here what the problem is (the behaviour you think is incorrect in #1451 is a symptom of this, but was correctly closed as the issue was how you configured the response) so I'm happy to review any PRs you wish to open and help where I can. |
I appreciate your time @JonRowe, @pirj. Sorry if my phrasing isn't always the best, but I want to be clear that the original issue isn't a symptom of how the response was configured. It's an issue of the concept I outline with I try to clarify this in the final thought -- where we can glean our understanding by a Ruby equivalent of the C implementation of class Base
def self.new(*args, &block)
instance = self.allocate # `self` will be incorrect here
instance.send(:initialize, *args, &block)
instance
end
end
class A < Base
end
class B < Base
end In this Ruby implementation of Which is the second thing I try to convey -- a user can't resolve it given the tools that they have available. If the "true" receiver of our call to allow(A).to receive(:foo).and_wrap_original do |method, receiver|
"#{method.unbind.bind(receiver).call}(mocked)"
end.and_yield_receiver_to_implementation That would work if the The likely easy to merge option is around the How would you both feel about that change as an initial implementation to consider/document? |
Subject of the issue
The
method
variable that's provided to the block inand_wrap_original
is bound to the mocked class but should instead be bound toself
, which won't be the same as the mocked class when using anything that has inherited from our mocked class.Your environment
Steps to reproduce
Alright, I'll try to distill this down to the simplest example. Let's start with a fairly basic inheritance structure like the following:
So we have a really simple example of how
A
andB
don't implement a given class method and so the implementation inBase
is used. We can look at that in an irb session:All of the
.foo
methods reference the same source location -- irb line 2, which is where it was defined when I pasted it into the console. More importantly, we can see that a simplified but reasonable model, is to imagine that callingA.foo
andB.foo
is handled by traversing the inheritance hierarchy untilBase.foo
is found, whereself
will reference the class that we're traversing from. This is important for the rest of the discussion.What I'm trying to surface here is that
self
is critically important for both our.new
and.foo
methods to be properly handled on theA
andB
classes. Right?Regardless of what class we call
.foo
from,self
should reference that class. If we callB.foo
, we obviously expectself
inside of any calls up the inheritance hierarchy to beB
, and here is where I believe the issue is. So let's highlight that in an example:Let's focus in on that last line, and then consider that there seems to be no way to work around this. The
method
variable, which is all we have to work with, is bound toA
and so any reference toself
all the way up the inheritance hierarchy for this method will always beA
regardless of whatself
refers to inside of any other class method inside ofB
. This starts to get really strange, right? When you think on it that way, you might see the number of complex issues this can cause.I've had a couple hours to dig into the
rspec-mocks
library and have worked out what the issue is, and it can be described by this snippet, where we don't see the problem. The difference is that we actively bind the original method to the correct object each time our replacement method is called. When we do this, the unbound method can be bound to each object correctly, and not simply onA
.Expected behavior
After mocking
A.foo
, we expect that when callingB.foo
, we would still have a correct reference toself
inside ourBase.foo
method.Actual behavior
After mocking
A.foo
, we see that when callingB.foo
,self
inside ourBase.foo
method refers toA
.Another way to put this, is that when we
define_singleton_method
onA
, we're also redefining the method that will be called when we callB.foo
, and soself
is still really important in regards to what we bind to. We can't simply bind the original method toA
, we have to bind it toself
, which then respects the classes that inherit fromA
.As a final thought, it's the same core issue that caused me to open #1451, which I think was prematurely closed, because when we get into it,
Object.new
is exactly like ourBase.foo
example, but in C. In Ruby we can imagine that as being implemented as:We can then see that if
self
is broken anywhere along our inheritance hierarchy, as it is when we take a method and bind it to the wrong object, we'll get strange things, like instances ofA
when we callB.new
.The text was updated successfully, but these errors were encountered: