Skip to content

Commit

Permalink
Merge pull request #1411 from askreet/and_invoke
Browse files Browse the repository at this point in the history
Add `and_invoke` for sequential mixed (return/raise) responses.
  • Loading branch information
JonRowe committed Feb 16, 2021
1 parent 38de6cd commit 77ed590
Show file tree
Hide file tree
Showing 8 changed files with 254 additions and 4 deletions.
4 changes: 2 additions & 2 deletions DEV-README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ Or ...
bundle install --binstubs
bin/rspec

## Customize the dev enviroment
## Customize the dev environment

The Gemfile includes the gems you'll need to be able to run specs. If you want
to customize your dev enviroment with additional tools like guard or
to customize your dev environment with additional tools like guard or
ruby-debug, add any additional gem declarations to Gemfile-custom (see
Gemfile-custom.sample for some examples).
1 change: 1 addition & 0 deletions features/configuring_responses/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ methods are provided to configure how the test double responds to the message.

* <a href="./configuring-responses/returning-a-value">`and_return`</a>
* <a href="./configuring-responses/raising-an-error">`and_raise`</a>
* <a href="./configuring-responses/mixed-responses">`and_invoke`</a>
* <a href="./configuring-responses/throwing">`and_throw`</a>
* <a href="./configuring-responses/yielding">`and_yield`</a>
* <a href="./configuring-responses/calling-the-original-implementation">`and_call_original`</a>
Expand Down
21 changes: 21 additions & 0 deletions features/configuring_responses/mixed_responses.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Feature: Mixed responses

Use `and_invoke` to invoke a Proc when a message is received. Pass `and_invoke` multiple
Procs to have different behavior for consecutive calls. The final Proc will continue to be
called if the message is received additional times.

Scenario: Mixed responses
Given a file named "raises_and_then_returns.rb" with:
"""ruby
RSpec.describe "when the method is called multiple times" do
it "raises and then later returns a value" do
dbl = double
allow(dbl).to receive(:foo).and_invoke(lambda { raise "failure" }, lambda { true })
expect { dbl.foo }.to raise_error("failure")
expect(dbl.foo).to eq(true)
end
end
"""
When I run `rspec raises_and_then_returns.rb`
Then the examples should all pass
2 changes: 1 addition & 1 deletion lib/rspec/mocks/matchers/receive_message_chain.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def initialize(chain, &block)
@recorded_customizations = []
end

[:with, :and_return, :and_throw, :and_raise, :and_yield, :and_call_original].each do |msg|
[:with, :and_return, :and_invoke, :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
Expand Down
62 changes: 61 additions & 1 deletion lib/rspec/mocks/message_expectation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class MessageExpectation
# etc.
#
# If the message is received more times than there are values, the last
# value is received for every subsequent call.
# value is returned for every subsequent call.
#
# @return [nil] No further chaining is supported after this.
# @example
Expand Down Expand Up @@ -85,6 +85,48 @@ def and_return(first_value, *values)
nil
end

# Tells the object to invoke a Proc when it receives the message. Given
# more than one value, the result of the first Proc is returned the first
# time the message is received, the result of the second Proc is returned
# the next time, etc, etc.
#
# If the message is received more times than there are Procs, the result of
# the last Proc is returned for every subsequent call.
#
# @return [nil] No further chaining is supported after this.
# @example
# allow(api).to receive(:get_foo).and_invoke(-> { raise ApiTimeout })
# api.get_foo # => raises ApiTimeout
# api.get_foo # => raises ApiTimeout
#
# allow(api).to receive(:get_foo).and_invoke(-> { raise ApiTimeout }, -> { raise ApiTimeout }, -> { :a_foo })
# api.get_foo # => raises ApiTimeout
# api.get_foo # => rasies ApiTimeout
# api.get_foo # => :a_foo
# api.get_foo # => :a_foo
# api.get_foo # => :a_foo
# # etc
def and_invoke(first_proc, *procs)
raise_already_invoked_error_if_necessary(__method__)
if negative?
raise "`and_invoke` is not supported with negative message expectations"
end

if block_given?
raise ArgumentError, "Implementation blocks aren't supported with `and_invoke`"
end

procs.unshift(first_proc)
if procs.any? { |p| !p.respond_to?(:call) }
raise ArgumentError, "Arguments to `and_invoke` must be callable."
end

@expected_received_count = [@expected_received_count, procs.size].max unless ignoring_args? || (@expected_received_count == 0 && @at_least)
self.terminal_implementation_action = AndInvokeImplementation.new(procs)

nil
end

# Tells the object to delegate to the original unmodified method
# when it receives the message.
#
Expand Down Expand Up @@ -683,6 +725,24 @@ def call(*_args_to_ignore, &_block)
end
end

# Handles the implementation of an `and_invoke` implementation.
# @private
class AndInvokeImplementation
def initialize(procs_to_invoke)
@procs_to_invoke = procs_to_invoke
end

def call(*args, &block)
proc = if @procs_to_invoke.size > 1
@procs_to_invoke.shift
else
@procs_to_invoke.first
end

proc.call(*args, &block)
end
end

# Represents a configured implementation. Takes into account
# any number of sub-implementations.
# @private
Expand Down
45 changes: 45 additions & 0 deletions spec/rspec/mocks/and_invoke_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module RSpec
module Mocks
RSpec.describe 'and_invoke' do
let(:obj) { double('obj') }

context 'when a block is passed' do
it 'raises ArgumentError' do
expect {
allow(obj).to receive(:foo).and_invoke('bar') { 'baz' }
}.to raise_error(ArgumentError, /implementation block/i)
end
end

context 'when no argument is passed' do
it 'raises ArgumentError' do
expect { allow(obj).to receive(:foo).and_invoke }.to raise_error(ArgumentError)
end
end

context 'when a non-callable are passed in any position' do
let(:non_callable) { nil }
let(:callable) { lambda { nil } }

it 'raises ArgumentError' do
error = [ArgumentError, "Arguments to `and_invoke` must be callable."]

expect { allow(obj).to receive(:foo).and_invoke(non_callable) }.to raise_error(*error)
expect { allow(obj).to receive(:foo).and_invoke(callable, non_callable) }.to raise_error(*error)
end
end

context 'when calling passed callables' do
let(:dbl) { double }

it 'passes the arguments into the callable' do
expect(dbl).to receive(:square_then_cube).and_invoke(lambda { |i| i ** 2 },
lambda { |i| i ** 3 })

expect(dbl.square_then_cube(2)).to eq 4
expect(dbl.square_then_cube(2)).to eq 8
end
end
end
end
end
6 changes: 6 additions & 0 deletions spec/rspec/mocks/matchers/receive_message_chain_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ module RSpec::Mocks::Matchers
expect(object.to_a.length).to eq(3)
end

it "works with and_invoke" do
allow(object).to receive_message_chain(:to_a, :length).and_invoke(lambda { raise "error" })

expect { object.to_a.length }.to raise_error("error")
end

it "can constrain the return value by the argument to the last call" do
allow(object).to receive_message_chain(:one, :plus).with(1) { 2 }
allow(object).to receive_message_chain(:one, :plus).with(2) { 3 }
Expand Down
117 changes: 117 additions & 0 deletions spec/rspec/mocks/multiple_invoke_handler_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
module RSpec
module Mocks
RSpec.describe "a message expectation with multiple invoke handlers and no specified count" do
let(:a_double) { double }

before(:each) do
expect(a_double).to receive(:do_something).and_invoke(lambda { 1 }, lambda { raise "2" }, lambda { 3 })
end

it "invokes procs in order" do
expect(a_double.do_something).to eq 1
expect { a_double.do_something }.to raise_error("2")
expect(a_double.do_something).to eq 3
verify a_double
end

it "falls back to a previously stubbed value" do
allow(a_double).to receive_messages :do_something => :stub_result
expect(a_double.do_something).to eq 1
expect { a_double.do_something }.to raise_error("2")
expect(a_double.do_something).to eq 3
expect(a_double.do_something).to eq :stub_result
end

it "fails when there are too few calls (if there is no stub)" do
a_double.do_something
expect { a_double.do_something }.to raise_error("2")
expect { verify a_double }.to fail
end

it "fails when there are too many calls (if there is no stub)" do
a_double.do_something
expect { a_double.do_something }.to raise_error("2")
a_double.do_something
a_double.do_something
expect { verify a_double }.to fail
end
end

RSpec.describe "a message expectation with multiple invoke handlers with a specified count equal to the number of values" do
let(:a_double) { double }

before(:each) do
expect(a_double).to receive(:do_something).exactly(3).times.and_invoke(lambda { 1 }, lambda { raise "2" }, lambda { 3 })
end

it "returns values in order to consecutive calls" do
expect(a_double.do_something).to eq 1
expect { a_double.do_something }.to raise_error("2")
expect(a_double.do_something).to eq 3
verify a_double
end
end

RSpec.describe "a message expectation with multiple invoke handlers specifying at_least less than the number of values" do
let(:a_double) { double }

before { expect(a_double).to receive(:do_something).at_least(:twice).with(no_args).and_invoke(lambda { 11 }, lambda { 22 }) }

it "uses the last return value for subsequent calls" do
expect(a_double.do_something).to equal(11)
expect(a_double.do_something).to equal(22)
expect(a_double.do_something).to equal(22)
verify a_double
end

it "fails when called less than the specified number" do
expect(a_double.do_something).to equal(11)
expect { verify a_double }.to fail
end

context "when method is stubbed too" do
before { allow(a_double).to receive(:do_something).and_invoke lambda { :stub_result } }

it "uses the last value for subsequent calls" do
expect(a_double.do_something).to equal(11)
expect(a_double.do_something).to equal(22)
expect(a_double.do_something).to equal(22)
verify a_double
end

it "fails when called less than the specified number" do
expect(a_double.do_something).to equal(11)
expect { verify a_double }.to fail
end
end
end

RSpec.describe "a message expectation with multiple invoke handlers with a specified count larger than the number of values" do
let(:a_double) { double }
before { expect(a_double).to receive(:do_something).exactly(3).times.and_invoke(lambda { 11 }, lambda { 22 }) }

it "uses the last return value for subsequent calls" do
expect(a_double.do_something).to equal(11)
expect(a_double.do_something).to equal(22)
expect(a_double.do_something).to equal(22)
verify a_double
end

it "fails when called less than the specified number" do
a_double.do_something
a_double.do_something
expect { verify a_double }.to fail
end

it "fails fast when called greater than the specified number" do
a_double.do_something
a_double.do_something
a_double.do_something

expect_fast_failure_from(a_double) do
a_double.do_something
end
end
end
end
end

0 comments on commit 77ed590

Please sign in to comment.