-
-
Notifications
You must be signed in to change notification settings - Fork 358
Add allow(...).to receive_message_chain #467
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
Feature: Message chains in the expect syntax | ||
|
||
You can use `receive_message_chain` to stub nested calls | ||
on both partial and pure mock objects. | ||
|
||
Scenario: allow a chained message | ||
Given a file named "spec/chained_messages.rb" with: | ||
"""ruby | ||
describe "a chained message expectation" do | ||
it "passes if the expected number of calls happen" do | ||
d = double | ||
allow(d).to receive_message_chain(:to_a, :length) | ||
|
||
d.to_a.length | ||
end | ||
end | ||
""" | ||
When I run `rspec spec/chained_messages.rb` | ||
Then the output should contain "1 example, 0 failures" | ||
|
||
Scenario: allow a chained message with a return value | ||
Given a file named "spec/chained_messages.rb" with: | ||
"""ruby | ||
describe "a chained message expectation" do | ||
it "passes if the expected number of calls happen" do | ||
d = double | ||
allow(d).to receive_message_chain(:to_a, :length).and_return(3) | ||
|
||
expect(d.to_a.length).to eq(3) | ||
end | ||
end | ||
""" | ||
When I run `rspec spec/chained_messages.rb` | ||
Then the output should contain "1 example, 0 failures" | ||
|
||
Scenario: expect a chained message with a return value | ||
Given a file named "spec/chained_messages.rb" with: | ||
"""ruby | ||
describe "a chained message expectation" do | ||
it "passes if the expected number of calls happen" do | ||
d = double | ||
expect(d).to receive_message_chain(:to_a, :length).and_return(3) | ||
|
||
expect(d.to_a.length).to eq(3) | ||
end | ||
end | ||
""" | ||
When I run `rspec spec/chained_messages.rb` | ||
Then the output should contain "1 example, 0 failures" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
module RSpec | ||
module Mocks | ||
module AnyInstance | ||
# @private | ||
class ExpectChainChain < StubChain | ||
def initialize(*args) | ||
super | ||
@expectation_fulfilled = false | ||
end | ||
|
||
def expectation_fulfilled? | ||
@expectation_fulfilled | ||
end | ||
|
||
def playback!(instance) | ||
super.tap { @expectation_fulfilled = true } | ||
end | ||
|
||
private | ||
|
||
def create_message_expectation_on(instance) | ||
::RSpec::Mocks::ExpectChain.expect_chain_on(instance, *@expectation_args, &@expectation_block) | ||
end | ||
|
||
def invocation_order | ||
@invocation_order ||= { | ||
:and_return => [nil], | ||
:and_raise => [nil], | ||
:and_yield => [nil] | ||
} | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -49,6 +49,15 @@ def stub_chain(*method_names_and_optional_return_values, &block) | |
end | ||
end | ||
|
||
# @api private | ||
def expect_chain(*method_names_and_optional_return_values, &block) | ||
@expectation_set = true | ||
normalize_chain(*method_names_and_optional_return_values) do |method_name, args| | ||
observe!(method_name) | ||
message_chains.add(method_name, ExpectChainChain.new(self, *args, &block)) | ||
end | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd prefer not to add this. IMO, one win of the new syntax is that things compose nicely so that expecting a chain falls out of it naturally. I want to encourage folks to upgrade, so I don't see a reason to backport the feature to the old syntax. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nevermind: I see now that this is needed for your implementation. I originally thought that you had added it so that folks could do This needs some kind of YARD docs. I'd prefer to have it labeled There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now labelled as api private. |
||
|
||
# Initializes the recording a message expectation to be played back | ||
# against any instance of this object that invokes the submitted | ||
# method. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
module RSpec | ||
module Mocks | ||
module Matchers | ||
#@api private | ||
class ReceiveMessageChain | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A big part of being SemVer compliant is being explicit about what is part of the public API and what is not. Every class should have yard comments documenting that. I would make this |
||
def initialize(chain, &block) | ||
@chain = chain | ||
@block = block | ||
@recorded_customizations = [] | ||
end | ||
|
||
[:and_return, :and_throw, :and_raise, :and_yield, :and_call_original].each do |msg| | ||
define_method(msg) do |*args, &block| | ||
@recorded_customizations << ExpectationCustomization.new(msg, args, block) | ||
self | ||
end | ||
end | ||
|
||
def name | ||
"receive_message_chain" | ||
end | ||
|
||
def setup_allowance(subject, &block) | ||
chain = StubChain.stub_chain_on(subject, *@chain, &(@block || block)) | ||
replay_customizations(chain) | ||
end | ||
|
||
def setup_any_instance_allowance(subject, &block) | ||
recorder = ::RSpec::Mocks.any_instance_recorder_for(subject) | ||
chain = recorder.stub_chain(*@chain, &(@block || block)) | ||
replay_customizations(chain) | ||
end | ||
|
||
def setup_any_instance_expectation(subject, &block) | ||
recorder = ::RSpec::Mocks.any_instance_recorder_for(subject) | ||
chain = recorder.expect_chain(*@chain, &(@block || block)) | ||
replay_customizations(chain) | ||
end | ||
|
||
def setup_expectation(subject, &block) | ||
chain = ExpectChain.expect_chain_on(subject, *@chain, &(@block || block)) | ||
replay_customizations(chain) | ||
end | ||
|
||
def setup_negative_expectation(*args) | ||
raise NegationUnsupportedError.new( | ||
"`expect(...).not_to receive_message_chain` is not supported " + | ||
"since it doesn't really make sense. What would it even mean?" | ||
) | ||
end | ||
|
||
alias matches? setup_expectation | ||
alias does_not_match? setup_negative_expectation | ||
|
||
private | ||
|
||
def replay_customizations(chain) | ||
@recorded_customizations.each do |customization| | ||
customization.playback_onto(chain) | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
module RSpec | ||
module Mocks | ||
# @private | ||
class MessageChain | ||
attr_reader :object, :chain, :block | ||
|
||
def initialize(object, *chain, &blk) | ||
@object = object | ||
@chain, @block = format_chain(*chain, &blk) | ||
end | ||
|
||
# @api private | ||
def setup_chain | ||
if chain.length > 1 | ||
if matching_stub = find_matching_stub | ||
chain.shift | ||
chain_on(matching_stub.invoke(nil), *chain, &@block) | ||
elsif matching_expectation = find_matching_expectation | ||
chain.shift | ||
chain_on(matching_expectation.invoke_without_incrementing_received_count(nil), *chain, &@block) | ||
else | ||
next_in_chain = Double.new | ||
expectation(object, chain.shift, next_in_chain) | ||
chain_on(next_in_chain, *chain, &@block) | ||
end | ||
else | ||
::RSpec::Mocks.allow_message(object, chain.shift, {}, &block) | ||
end | ||
end | ||
|
||
private | ||
|
||
def expectation(object, message, returned_object) | ||
raise NotImplementedError.new | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I generally like this pattern when end users are meant to subclass (as the definition with
What do you think about removing this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
|
||
def chain_on(object, *chain, &block) | ||
initialize(object, *chain, &block) | ||
setup_chain | ||
end | ||
|
||
def format_chain(*chain, &blk) | ||
if Hash === chain.last | ||
hash = chain.pop | ||
hash.each do |k,v| | ||
chain << k | ||
blk = lambda { v } | ||
end | ||
end | ||
return chain.join('.').split('.'), blk | ||
end | ||
|
||
def find_matching_stub | ||
::RSpec::Mocks.proxy_for(object). | ||
__send__(:find_matching_method_stub, chain.first.to_sym) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry I didn't notice this before...but it looks like this needs to be defined in the subclasses as well: there's expect(obj).to receive(:foo)
expect(obj).to receive_message_chain(:foo, :bar, :bazz) I'm not 100% sure on that, though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll take a look. |
||
end | ||
|
||
def find_matching_expectation | ||
::RSpec::Mocks.proxy_for(object). | ||
__send__(:find_matching_expectation, chain.first.to_sym) | ||
end | ||
end | ||
|
||
# @private | ||
class ExpectChain < MessageChain | ||
# @api private | ||
def self.expect_chain_on(object, *chain, &blk) | ||
new(object, *chain, &blk).setup_chain | ||
end | ||
|
||
private | ||
|
||
def expectation(object, message, returned_object) | ||
::RSpec::Mocks.expect_message(object, message, {}) { returned_object } | ||
end | ||
end | ||
|
||
# @private | ||
class StubChain < MessageChain | ||
def self.stub_chain_on(object, *chain, &blk) | ||
new(object, *chain, &blk).setup_chain | ||
end | ||
|
||
private | ||
|
||
def expectation(object, message, returned_object) | ||
::RSpec::Mocks.allow_message(object, message, {}) { returned_object } | ||
end | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a little odd that the primary usage of this (e.g.
allow(...).to receive_message_chain(...)
) isn't highlighted here, and instead theexpect
form is. Also, callingreceive_message_chain
a "matcher" is stretching it (even though it implements the rspec-expectations matcher protocol). I'd probably phrase this something like:receive_message_chain
which provides the functionality of the oldstub_chain
for the new allow/expect syntax. Use it like so:allow(...).to receive_message_chain(:foo, :bar, :bazz)
. (Sam Phippen).