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

Refactoring Mocks::Proxy to make it easier to treat blocks similarly to args for expectations matchers like a_block #1241

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
524637d
rspec-expectations #1065: add support for a_block expectation matcher
kaiwren Sep 16, 2018
bfd1b20
Partially refactored out ReceivedMessage from Proxy#message_received
kaiwren Sep 22, 2018
714034b
- Remove premature memoization
kaiwren Sep 22, 2018
930f14a
Move logic from RSpec::Proxy into RSpec::ReceivedMessage
kaiwren Sep 22, 2018
1366c79
Extract concrete ReceivedMessage and ReceivedMessages from Proxy, and…
kaiwren Sep 30, 2018
0db73df
Make ReceivedMessages#partition return ReceivedMessages (and not arra…
kaiwren Sep 30, 2018
3153076
Add TODO to Proxy::message_received to figure out why has_negative_ex…
kaiwren Sep 30, 2018
2a82bde
Improve specs for a_block matcher and handle more edgecases
JonRowe Sep 17, 2018
7848e77
- Merge branch '1065-with-a-block' into refactor-extract-ReceivedMess…
kaiwren Oct 12, 2018
209fb26
Added specs to cover matching mocked and stubbed methods with a &bloc…
kaiwren Oct 13, 2018
2a17542
Adding documentation to a_block
kaiwren Oct 13, 2018
58f44ab
Fixing linting issues to do with whitespaces
kaiwren Oct 13, 2018
6ae1105
Removed space in 'before ()' in specs that was causing the linter to …
kaiwren Oct 14, 2018
9bc5f79
Fixed spelling error in feature name - see https://github.com/rspec/r…
kaiwren Oct 14, 2018
99adb75
Removed space in 'let ()' in specs that was causing the linter to fai…
kaiwren Oct 14, 2018
2ecf7be
replace -> with lambda for 1.8.7
kaiwren Oct 14, 2018
8a27c2a
replace -> with lambda for 1.8.7 in specs - and that's the last -> in…
kaiwren Oct 14, 2018
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
25 changes: 25 additions & 0 deletions features/setting_constraints/matching_arguments.feature
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,28 @@ Feature: Matching arguments
"""
When I run `rspec responding_differently_spec.rb`
Then the examples should all pass

Scenario: Expecting a method invocatin with a block
Copy link
Contributor

Choose a reason for hiding this comment

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

"invocation"

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, fixed in 9bc5f79

Given a file named "expect_method_with_block.rb" with:
"""ruby
RSpec.describe "Expecting a message with a block" do
let(:dbl) { double }
before { expect(dbl).to receive(:foo).with(a_block) }

it "passes when the method is invoked with a block" do
dbl.foo { |a| :block }
end

it "fails when the method is invoked without a block" do
dbl.foo
end
end
"""
When I run `rspec expect_method_with_block.rb`
Then it should fail with the following output:
| 2 examples, 1 failure |
| |
| Failure/Error: dbl.foo |
| #<Double (anonymous)> received :foo with unexpected arguments |
| expected: (a block) |
| got: (no args) |
2 changes: 2 additions & 0 deletions lib/rspec/mocks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
method_double
argument_matchers
example_methods
received_message
received_messages
proxy
test_double
argument_list_matcher
Expand Down
9 changes: 7 additions & 2 deletions lib/rspec/mocks/argument_list_matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,13 @@ def initialize(*expected_args)
# position of the arguments passed to `new`.
#
# @see #initialize
def args_match?(*args)
Support::FuzzyMatcher.values_match?(resolve_expected_args_based_on(args), args)
def args_match?(*args, &block)
expected_args = resolve_expected_args_based_on(args)
if ArgumentMatchers::BlockMatcher::INSTANCE == expected_args.last
Support::FuzzyMatcher.values_match?(expected_args, args + [block])
else
Support::FuzzyMatcher.values_match?(expected_args, args)
end
end

# @private
Expand Down
27 changes: 27 additions & 0 deletions lib/rspec/mocks/argument_matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,22 @@ def kind_of(klass)

alias_method :a_kind_of, :kind_of

# Matches if a block is passed implicitly i.e is invoked
# using `yield` in the receiving method
#
# @example
# expect(object).to receive(:message).with(a_block)
#
# # matches any of these:
# object.message { }
# empty_lambda = lambda { }
# object.message(&empty_lambda)
# empty_proc = Proc.new { }
# object.message(&empty_proc)
def a_block
BlockMatcher::INSTANCE
end

# @private
def self.anythingize_lonely_keys(*args)
hash = Hash === args.last ? args.delete_at(-1) : {}
Expand Down Expand Up @@ -301,6 +317,17 @@ def description
end
end

# @private
class BlockMatcher < SingletonMatcher
def ===(block)
block.kind_of?(Proc)
end

def description
"a block"
end
end

matcher_namespace = name + '::'
::RSpec::Support.register_matcher_definition do |object|
# This is the best we have for now. We should tag all of our matchers
Expand Down
4 changes: 2 additions & 2 deletions lib/rspec/mocks/message_expectation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,8 @@ def yield_receiver_to_implementation_block?
@yield_receiver_to_implementation_block
end

def matches?(message, *args)
@message == message && @argument_list_matcher.args_match?(*args)
def matches?(message, *args, &block)
@message == message && @argument_list_matcher.args_match?(*args, &block)
end

def safe_invoke(parent_stub, *args, &block)
Expand Down
94 changes: 32 additions & 62 deletions lib/rspec/mocks/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def initialize(object, order_group, options={})
@object = object
@order_group = order_group
@error_generator = ErrorGenerator.new(object)
@messages_received = []
@received_messages = ReceivedMessages.new
@options = options
@null_object = false
@method_doubles = Hash.new { |h, k| h[k] = MethodDouble.new(@object, k, self) }
Expand Down Expand Up @@ -90,27 +90,20 @@ def replay_received_message_on(expectation, &block)
@error_generator.raise_expectation_on_unstubbed_method(expected_method_name)
end

@messages_received.each do |(actual_method_name, args, received_block)|
next unless expectation.matches?(actual_method_name, *args)

expectation.safe_invoke(nil)
block.call(*args, &received_block) if block
end
@received_messages.replay_on(expectation, &block)
end

# @private
def check_for_unexpected_arguments(expectation)
return if @messages_received.empty?
return if @received_messages.empty?

return if @messages_received.any? { |method_name, args, _| expectation.matches?(method_name, *args) }
return if @received_messages.any_matching_message_for?(expectation)

name_but_not_args, others = @messages_received.partition do |(method_name, args, _)|
expectation.matches_name_but_not_args(method_name, *args)
end
name_but_not_args, others = @received_messages.partition_by_matches_name_not_args_for(expectation)

return if name_but_not_args.empty? && !others.empty?

expectation.raise_unexpected_message_args_error(name_but_not_args.map { |args| args[1] })
expectation.raise_unexpected_message_args_error(name_but_not_args.all_args)
end

# @private
Expand Down Expand Up @@ -141,17 +134,17 @@ def verify

# @private
def reset
@messages_received.clear
@received_messages.clear
end

# @private
def received_message?(method_name, *args, &block)
@messages_received.any? { |array| array == [method_name, args, block] }
@received_messages.received?(method_name, *args, &block)
end

# @private
def messages_arg_list
@messages_received.map { |_, args, _| args }
@received_messages.all_args
end

# @private
Expand All @@ -162,39 +155,28 @@ def has_negative_expectation?(message)
# @private
def record_message_received(message, *args, &block)
@order_group.invoked SpecificMessage.new(object, message, args)
@messages_received << [message, args, block]
received_message = build_received_message(message, *args, &block)
@received_messages << received_message
received_message
end

# @private
# @see RSpec::Mocks::ReceivedMessage
def message_received(message, *args, &block)
record_message_received message, *args, &block

expectation = find_matching_expectation(message, *args)
stub = find_matching_method_stub(message, *args)
received_message = record_message_received(message, *args, &block)
# TODO: Why does `has_negative_expectation?` need to be delayed?
received_message.process!(
@object,
@error_generator,
null_object?,
-> { has_negative_expectation?(message) },
messages_arg_list
)
end

if (stub && expectation && expectation.called_max_times?) || (stub && !expectation)
expectation.increase_actual_received_count! if expectation && expectation.actual_received_count_matters?
if (expectation = find_almost_matching_expectation(message, *args))
expectation.advise(*args) unless expectation.expected_messages_received?
end
stub.invoke(nil, *args, &block)
elsif expectation
expectation.unadvise(messages_arg_list)
expectation.invoke(stub, *args, &block)
elsif (expectation = find_almost_matching_expectation(message, *args))
expectation.advise(*args) if null_object? unless expectation.expected_messages_received?

if null_object? || !has_negative_expectation?(message)
expectation.raise_unexpected_message_args_error([args])
end
elsif (stub = find_almost_matching_stub(message, *args))
stub.advise(*args)
raise_missing_default_stub_error(stub, [args])
elsif Class === @object
@object.superclass.__send__(message, *args, &block)
else
@object.__send__(:method_missing, message, *args, &block)
end
# @private
def build_received_message(message_name, *args, &block)
ReceivedMessage.new(message_name, method_double_for(message_name), *args, &block)
end

# @private
Expand Down Expand Up @@ -241,27 +223,15 @@ def method_double_for(message)
end

def find_matching_expectation(method_name, *args)
find_best_matching_expectation_for(method_name) do |expectation|
expectation.matches?(method_name, *args)
end
build_received_message(
method_name, *args
).find_matching_expectation
end

def find_almost_matching_expectation(method_name, *args)
find_best_matching_expectation_for(method_name) do |expectation|
expectation.matches_name_but_not_args(method_name, *args)
end
end

def find_best_matching_expectation_for(method_name)
first_match = nil

method_double_for(method_name).expectations.each do |expectation|
next unless yield expectation
return expectation unless expectation.called_max_times?
first_match ||= expectation
end

first_match
build_received_message(
method_name, *args
).find_almost_matching_expectation
end

def find_matching_method_stub(method_name, *args)
Expand Down
90 changes: 90 additions & 0 deletions lib/rspec/mocks/received_message.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
module RSpec
module Mocks
# @private
# @see RSpec::Mocks::Proxy
class ReceivedMessage
attr_reader :name, :args, :block

def initialize(message_name, method_double, *args, &block)
@name = message_name
@method_double = method_double
@args = args
@block = block
end

def process!(receiving_object, error_generator, is_null_object, has_negative_expectation, messages_arg_list)
message = @name
received_message = self
args = @args
block = @block
expectation = received_message.find_matching_expectation
stub = received_message.find_matching_stub

if received_message.matching_stub_and_matching_expectation_and_expectation_maxxed? || received_message.only_matching_stub?
expectation.increase_actual_received_count! if expectation && expectation.actual_received_count_matters?
if (expectation = find_almost_matching_expectation)
expectation.advise(*args) unless expectation.expected_messages_received?
end
stub.invoke(nil, *args, &block)
elsif expectation
expectation.unadvise(messages_arg_list)
expectation.invoke(stub, *args, &block)
elsif (expectation = find_almost_matching_expectation)
expectation.advise(*args) if is_null_object unless expectation.expected_messages_received?

if is_null_object || !has_negative_expectation.call
expectation.raise_unexpected_message_args_error([args])
end
elsif (stub = find_almost_matching_stub)
stub.advise(*args)
error_generator.raise_missing_default_stub_error(stub, [args])
elsif Class === receiving_object
receiving_object.superclass.__send__(message, *args, &block)
else
receiving_object.__send__(:method_missing, message, *args, &block)
end
end

def find_matching_stub
@method_double.stubs.find { |stub| stub.matches?(@name, *@args, &@block) }
end

def find_matching_expectation
find_best_matching_expectation do |expectation|
expectation.matches?(@name, *@args, &@block)
end
end

def find_almost_matching_stub
@method_double.stubs.find { |stub| stub.matches_name_but_not_args(@name, *@args) }
end

def find_almost_matching_expectation
find_best_matching_expectation do |expectation|
expectation.matches_name_but_not_args(@name, *@args)
end
end

def matching_stub_and_matching_expectation_and_expectation_maxxed?
find_matching_stub && find_matching_expectation && find_matching_expectation.called_max_times?
end

def only_matching_stub?
find_matching_stub && !find_matching_expectation
end

private
def find_best_matching_expectation
first_match = nil

@method_double.expectations.each do |expectation|
next unless yield expectation
return expectation unless expectation.called_max_times?
first_match ||= expectation
end

first_match
end
end
end
end
Loading