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

Reduce duplication in & between Mock & ObjectMethods #431

Open
wants to merge 34 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
22c594f
make arg iteration similar in expects & stubs
nitishr Nov 29, 2019
ffac2ad
Refactor: extract add_expectation to DRY expects & stubs
nitishr Nov 29, 2019
f700c36
Refactor: inline temp - expectation
nitishr Nov 29, 2019
da280b1
Refactor: inline temp - iterator
nitishr Nov 29, 2019
7579416
Refactor: extract anticipates to DRY expects,stubs
nitishr Nov 29, 2019
7cff8fa
Refactor: inline temp - method
nitishr Nov 29, 2019
7494b2d
Refactor: extract stub_method to be moved to Mockery
nitishr Nov 29, 2019
5f83af5
Refactor: extract & reuse stubbed_method
nitishr Nov 29, 2019
b9422ba
Refactor: move stub_method to Mockery from ObjectMethods
nitishr Nov 29, 2019
7a691d9
Refactor: make on_stubbing{,_method_unnecessarily} private
nitishr Nov 29, 2019
bf32420
Refactor: rename on_stubbingx to check_stubbingx
nitishr Nov 29, 2019
73cf959
Refactor: no need for stateful obj for arg iter-n
nitishr Nov 29, 2019
b34726e
substitute algorithm for ArgumentIterator.each
nitishr Nov 30, 2019
547a4d5
Refactor: extract anticipates to DRY expects,stubs
nitishr Nov 30, 2019
bf7ef4e
Refactor: inline add_expectation
nitishr Nov 30, 2019
84e3ee7
no need to pass caller around
nitishr Nov 30, 2019
bfd20d8
ArgumentIterator.each returns the last result anyway
nitishr Nov 30, 2019
9761be3
Refactor: inline temp - mocker to restrict scope
nitishr Nov 30, 2019
b2354df
call Mock#{expects,stubs} with methods_vs_return_values
nitishr Dec 1, 2019
7e7a61b
Refactor: prep to move by inlining temp - method
nitishr Dec 1, 2019
b87733f
Refactor: replace ending yield w/ call in sequence
nitishr Dec 1, 2019
2c4a5db
call stub_method alongside expectation setting for each method
nitishr Dec 1, 2019
262ea4c
add expectation to expectations after fully set up
nitishr Dec 1, 2019
5a5ed50
Refactor: rename stubbed_method->stubba_method_for
nitishr Dec 1, 2019
5f1c5f4
Refactor: move ArgumentIterator#each to Mock
nitishr Dec 2, 2019
365884e
Refactor: inline each_argument
nitishr Dec 2, 2019
19004e7
Refactor: DRY up {PRE_RUBY_V19,RUBY_V19_AND_LATER}_EXCLUDED_METHODS
nitishr Dec 2, 2019
201cfaa
Refactor: extract anticipates to DRY expects,stubs
nitishr Dec 2, 2019
2840411
Refactor: inline method error_if_frozen
nitishr Dec 2, 2019
583103b
more consistent setting of multiple expectations
nitishr Feb 19, 2020
b80cb98
move Mockery#stub_method call to ObjectMethods
nitishr Feb 20, 2020
8a7af66
remove the Spanish Inquisition special case
nitishr Feb 21, 2020
9930e5a
Refactor: inline anticipates into expects
nitishr Feb 21, 2020
1906a10
Refactor: ExpectationSetting->CompositeExpectation
nitishr Feb 21, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 0 additions & 17 deletions lib/mocha/argument_iterator.rb

This file was deleted.

34 changes: 16 additions & 18 deletions lib/mocha/mock.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
require 'mocha/receivers'
require 'mocha/method_matcher'
require 'mocha/parameters_matcher'
require 'mocha/argument_iterator'
require 'mocha/expectation_error_factory'
require 'mocha/ruby_version'

Expand Down Expand Up @@ -109,14 +108,7 @@ class Mock
# object.expects(:expected_method_one).returns(:result_one)
# object.expects(:expected_method_two).returns(:result_two)
def expects(method_name_or_hash, backtrace = nil)
iterator = ArgumentIterator.new(method_name_or_hash)
iterator.each do |*args|
method_name = args.shift
ensure_method_not_already_defined(method_name)
expectation = Expectation.new(self, method_name, backtrace)
expectation.returns(args.shift) unless args.empty?
@expectations.add(expectation)
end
anticipates(method_name_or_hash, backtrace)
end

# Adds an expectation that the specified method may be called any number of times with any parameters.
Expand Down Expand Up @@ -145,15 +137,7 @@ def expects(method_name_or_hash, backtrace = nil)
# object.stubs(:stubbed_method_one).returns(:result_one)
# object.stubs(:stubbed_method_two).returns(:result_two)
def stubs(method_name_or_hash, backtrace = nil)
iterator = ArgumentIterator.new(method_name_or_hash)
iterator.each do |*args|
method_name = args.shift
ensure_method_not_already_defined(method_name)
expectation = Expectation.new(self, method_name, backtrace)
expectation.at_least(0)
expectation.returns(args.shift) unless args.empty?
@expectations.add(expectation)
end
anticipates(method_name_or_hash, backtrace) { |expectation| expectation.at_least(0) }
end

# Removes the specified stubbed methods (added by calls to {#expects} or {#stubs}) and all expectations associated with them.
Expand Down Expand Up @@ -370,5 +354,19 @@ def ensure_method_not_already_defined(method_name)
def any_expectations?
@expectations.any?
end

# @private
def anticipates(method_name_or_hash, backtrace = nil, object = Mock.new(@mockery), &block)
Array(method_name_or_hash).map do |*args|
args = args.flatten
method_name = args.shift
Mockery.instance.stub_method(object, method_name) unless object.is_a?(Mock)
ensure_method_not_already_defined(method_name)
expectation = Expectation.new(self, method_name, backtrace)
expectation.returns(args.shift) unless args.empty?
yield expectation if block
@expectations.add(expectation)
end.last
Copy link
Member

Choose a reason for hiding this comment

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

Although I appreciate that this reduces the duplication, I'm not convinced that the resultant code is any easier to follow. In fact I think, if anything, it's a bit harder to follow! Let me have a think about how we might better remove the duplication.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for your review, @floehopper. Would you mind elaborating what aspects of this change you aren't convinced about and/or which ones you think make it harder to follow? If I understand your concerns more specifically, we might be able to come up with a better solution together.

To start with, I can share a bit more of my thinking here...

If it's the anticipates abstraction, I'll admit that I was initially unsure of it as well (and still am, but a lot less than I initially was). The reason I was unsure was I wondered if the need for me to look up (and cite the dictionary definitions in the PR description) was a sign of the concept being too smart/cute/nuanced.

So, I let it mull for a few days in my head and reached the conclusion that anticipates is probably a better abstraction covering both an expectation and an allowance (stub, in mocha's terminology). I've always found expectation to be too strong a term to represent an allowance. (Anticipation might be too strong a term, too - but it does seem less strong than expectation.) So, in the long term, I would even consider/suggest considering renaming, for instance, the class Expectation to Anticipation (and maybe introduce subclasses - Expectation and Allowance). I have of course not fully analyzed that chain of reasoning, and certainly not considered it from an API compatibility point of view. This was only trying to clarify the concepts and their nuances for myself.

Having said that, I'm not a native English speaker and may well have misinterpreted the shades of meanings of the various terms. So, I'd appreciate any corrections to my understanding or thinking.

Copy link
Member

Choose a reason for hiding this comment

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

Sorry for the slow reply. I'll try to explain my reservations:

  • Although the Mock#anticipates method has removed all the duplication between ObjectMethods#expects, ObjectMethods#stubs, Mock#expects & Mock#stubs (which is great BTW!), it's now really quite a long and "dense" method and, what's more, three of the lines are only executed conditionally. Coming at the final version of this method definition cold, I found it quite hard to follow.

  • I'm not completely convinced that the inlining of the ArgumentIterator is a net gain. Encapsulating the common processing of the arguments for the four API methods in a single place still makes sense to me, even though its exact abstraction may not be quite right. And having to work out what the combination of calls to Kernel#Array, Array#map, Array#flatten, Array#shift & Array#last is doing adds quite a lot of cognitive overhead to understanding the method definition.

  • The naming of Mock#anticipates doesn't bother me too much, although I don't think it is quite right. I wonder whether it would make more sense if we made an Expectation have a cardinality of Cardinality.at_least(0) (i.e. stubbing behaviour) by default, inline Mock#anticipates into Mock#stubs, and call Mock#stubs from Mock#expects passing a block setting the expectation cardinality to Cardinality.exactly(1) (i.e. default expecting behaviour). It feels as if the expecting behaviour is a superset of the stubbing behaviour. What do you think...?

Does make sense? I'm sorry I've struggled a bit to explain my thinking!

I've had time to go through the commits one-by-one in more detail this afternoon and I'd like to mention that I found them very easy to follow - so thank you for taking so much care to curate the commits and write useful commit messages.

Having looked through the commits in more detail, I'm inclined to have a go at merging a version of this PR, albeit with some changes as per my comments above. Is that OK with you?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's very helpful, @floehopper. I now understand your concerns much better.

I like the suggestion of expects constraining the Expectation, rather than stubs loosening it. Part of the reason I extracted anticipates rather than reuse expects, BTW, was to avoid changing the public API of the existing methods, even though the changes would be fully backward compatible (i.e. accepting but not expecting a block or optional arguments). So, that's a consideration.

I'd be happy with you merging a modified version of the PR with the changes you feel comfortable with. We could iterate on top of that for the more controversial/difficult aspects.

Copy link
Member

Choose a reason for hiding this comment

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

👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@floehopper, I'm planning to push another commit which I think will alleviate some of your concerns, while at the same time, improving mocha's behavior with expectation chaining by making it more consistent across single-method and multiple-method stubs/expects.

The code in that commit or even the approach could possibly be improved, but I'd like to convey the general idea/direction before I go too far.

Is that OK? If you'd rather that I wait for you to finish merging (a version of) this PR, that's fine, too. Just let me know.

Copy link
Member

Choose a reason for hiding this comment

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

BTW, was to avoid changing the public API of the existing methods, even though the changes would be fully backward compatible (i.e. accepting but not expecting a block or optional arguments). So, that's a consideration.

Good point! 😄

Is that OK? If you'd rather that I wait for you to finish merging (a version of) this PR, that's fine, too. Just let me know.

Yes, that's fine - please go ahead!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

I'm completely fine taking any or all of the newly added commits to a different PR so they can be discussed and reviewed on their own (or altogether dropping any/all of them). The newly introduced ExpectationSetting may need some redesign, but I hope the changes convey the general concept and approach.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like the suggestion of expects constraining the Expectation, rather than stubs loosening it.

Looking at the code after my last few updates, I think the current form expresses the intent better than the change we were thinking of.

def stubs(stubbed_methods_vs_return_values)
  expects(stubbed_methods_vs_return_values).at_least(0)
end

seem more intention-revealing and logical than

def expects(stubbed_methods_vs_return_values)
  stubs(stubbed_methods_vs_return_values).exactly(1)
end

Copy link
Member

Choose a reason for hiding this comment

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

@floehopper, I'm planning to push another commit which I think will alleviate some of your concerns, while at the same time, improving mocha's behavior with expectation chaining by making it more consistent across single-method and multiple-method stubs/expects.

The code in that commit or even the approach could possibly be improved, but I'd like to convey the general idea/direction before I go too far.

Is that OK? If you'd rather that I wait for you to finish merging (a version of) this PR, that's fine, too. Just let me know.

Done.

I'm completely fine taking any or all of the newly added commits to a different PR so they can be discussed and reviewed on their own (or altogether dropping any/all of them). The newly introduced ExpectationSetting may need some redesign, but I hope the changes convey the general concept and approach.

Sorry for the slow reply. Thanks for adding the extra commit - I'm going to take a look at it shortly.

end
end
end
27 changes: 16 additions & 11 deletions lib/mocha/mockery.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ def new_state_machine(name)
add_state_machine(StateMachine.new(name))
end

def stub_method(object, method_name)
check_stubbing(object, method_name)
stubba.stub(object.stubba_method_for(method_name))
end

def verify(assertion_counter = nil)
unless mocks.all? { |mock| mock.__verified__?(assertion_counter) }
message = "not all expectations were satisfied\n#{mocha_inspect}"
Expand All @@ -95,7 +100,7 @@ def verify(assertion_counter = nil)
expectations.each do |e|
unless Mocha.configuration.stubbing_method_unnecessarily == :allow
next if e.used?
on_stubbing_method_unnecessarily(e)
check_stubbing_method_unnecessarily(e)
end
end
end
Expand Down Expand Up @@ -125,7 +130,15 @@ def mocha_inspect
message
end

def on_stubbing(object, method)
attr_writer :logger

def logger
@logger ||= Logger.new($stderr)
end

private

def check_stubbing(object, method)
method = PRE_RUBY_V19 ? method.to_s : method.to_sym
method_signature = "#{object.mocha_inspect}.#{method}"
check(:stubbing_non_existent_method, 'non-existent method', method_signature) do
Expand All @@ -138,18 +151,10 @@ def on_stubbing(object, method)
check(:stubbing_method_on_non_mock_object, 'method on non-mock object', method_signature)
end

def on_stubbing_method_unnecessarily(expectation)
def check_stubbing_method_unnecessarily(expectation)
check(:stubbing_method_unnecessarily, 'method unnecessarily', expectation.method_signature, expectation.backtrace)
end

attr_writer :logger

def logger
@logger ||= Logger.new($stderr)
end

private

def check(action, description, method_signature, backtrace = caller)
return if block_given? && !yield
message = "stubbing #{description}: #{method_signature}"
Expand Down
50 changes: 17 additions & 33 deletions lib/mocha/object_methods.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
require 'mocha/mockery'
require 'mocha/instance_method'
require 'mocha/argument_iterator'
require 'mocha/expectation_error_factory'

module Mocha
Expand Down Expand Up @@ -40,6 +39,11 @@ def stubba_class
singleton_class
end

# @private
def stubba_method_for(method_name)
stubba_method.new(stubba_object, method_name)
end

# Adds an expectation that the specified method must be called exactly once with any parameters.
#
# The original implementation of the method is replaced during the test and then restored at the end of the test. The temporary replacement method has the same visibility as the original method.
Expand Down Expand Up @@ -72,21 +76,7 @@ def expects(expected_methods_vs_return_values)
if expected_methods_vs_return_values.to_s =~ /the[^a-z]*spanish[^a-z]*inquisition/i
raise ExpectationErrorFactory.build('NOBODY EXPECTS THE SPANISH INQUISITION!')
end
if frozen?
raise StubbingError.new("can't stub method on frozen object: #{mocha_inspect}", caller)
end
expectation = nil
mockery = Mocha::Mockery.instance
iterator = ArgumentIterator.new(expected_methods_vs_return_values)
iterator.each do |*args|
method_name = args.shift
mockery.on_stubbing(self, method_name)
method = stubba_method.new(stubba_object, method_name)
mockery.stubba.stub(method)
expectation = mocha.expects(method_name, caller)
expectation.returns(args.shift) unless args.empty?
end
expectation
anticipates(expected_methods_vs_return_values)
end

# Adds an expectation that the specified method may be called any number of times with any parameters.
Expand Down Expand Up @@ -118,21 +108,7 @@ def expects(expected_methods_vs_return_values)
#
# @see Mock#stubs
def stubs(stubbed_methods_vs_return_values)
if frozen?
raise StubbingError.new("can't stub method on frozen object: #{mocha_inspect}", caller)
end
expectation = nil
mockery = Mocha::Mockery.instance
iterator = ArgumentIterator.new(stubbed_methods_vs_return_values)
iterator.each do |*args|
method_name = args.shift
mockery.on_stubbing(self, method_name)
method = stubba_method.new(stubba_object, method_name)
mockery.stubba.stub(method)
expectation = mocha.stubs(method_name, caller)
expectation.returns(args.shift) unless args.empty?
end
expectation
anticipates(stubbed_methods_vs_return_values) { |expectation| expectation.at_least(0) }
end

# Removes the specified stubbed methods (added by calls to {#expects} or {#stubs}) and all expectations associated with them.
Expand Down Expand Up @@ -161,9 +137,17 @@ def stubs(stubbed_methods_vs_return_values)
def unstub(*method_names)
mockery = Mocha::Mockery.instance
method_names.each do |method_name|
method = stubba_method.new(stubba_object, method_name)
mockery.stubba.unstub(method)
mockery.stubba.unstub(stubba_method_for(method_name))
end
end

private

def anticipates(expected_methods_vs_return_values, &block)
if frozen?
raise StubbingError.new("can't stub method on frozen object: #{mocha_inspect}", caller)
end
mocha.anticipates(expected_methods_vs_return_values, caller, self, &block)
end
end
end
17 changes: 8 additions & 9 deletions test/unit/mock_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,19 @@ def test_should_be_equal
method_missing
singleton_method_undefined
initialize
Array
].freeze

MACOS_EXCLUDED_METHODS =
MACOS && MACOS_VERSION >= MACOS_MOJAVE_VERSION ? [:syscall] : []

RUBY_V19_AND_LATER_EXCLUDED_METHODS = [
:object_id,
:method_missing,
:singleton_method_undefined,
:initialize,
:String,
:singleton_method_added,
*MACOS_EXCLUDED_METHODS
].freeze
RUBY_V19_AND_LATER_EXCLUDED_METHODS =
(PRE_RUBY_V19_EXCLUDED_METHODS.map(&:to_sym) + [
:object_id,
:String,
:singleton_method_added,
*MACOS_EXCLUDED_METHODS
]).freeze

OBJECT_METHODS = STANDARD_OBJECT_PUBLIC_INSTANCE_METHODS.reject do |m|
(m =~ /^__.*__$/) ||
Expand Down