From 77ed590b5eb18a44f64b6e09b5e0d48217ac6ed9 Mon Sep 17 00:00:00 2001 From: Jon Rowe Date: Tue, 16 Feb 2021 21:27:10 +0000 Subject: [PATCH] Merge pull request #1411 from askreet/and_invoke Add `and_invoke` for sequential mixed (return/raise) responses. --- DEV-README.md | 4 +- features/configuring_responses/README.md | 1 + .../mixed_responses.feature | 21 ++++ .../mocks/matchers/receive_message_chain.rb | 2 +- lib/rspec/mocks/message_expectation.rb | 62 +++++++++- spec/rspec/mocks/and_invoke_spec.rb | 45 +++++++ .../matchers/receive_message_chain_spec.rb | 6 + .../mocks/multiple_invoke_handler_spec.rb | 117 ++++++++++++++++++ 8 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 features/configuring_responses/mixed_responses.feature create mode 100644 spec/rspec/mocks/and_invoke_spec.rb create mode 100644 spec/rspec/mocks/multiple_invoke_handler_spec.rb diff --git a/DEV-README.md b/DEV-README.md index cef861bab..580001651 100644 --- a/DEV-README.md +++ b/DEV-README.md @@ -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). diff --git a/features/configuring_responses/README.md b/features/configuring_responses/README.md index ccdf213b5..ab2618d6a 100644 --- a/features/configuring_responses/README.md +++ b/features/configuring_responses/README.md @@ -3,6 +3,7 @@ methods are provided to configure how the test double responds to the message. * `and_return` * `and_raise` +* `and_invoke` * `and_throw` * `and_yield` * `and_call_original` diff --git a/features/configuring_responses/mixed_responses.feature b/features/configuring_responses/mixed_responses.feature new file mode 100644 index 000000000..924dc596e --- /dev/null +++ b/features/configuring_responses/mixed_responses.feature @@ -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 diff --git a/lib/rspec/mocks/matchers/receive_message_chain.rb b/lib/rspec/mocks/matchers/receive_message_chain.rb index 583ecf70a..06419598e 100644 --- a/lib/rspec/mocks/matchers/receive_message_chain.rb +++ b/lib/rspec/mocks/matchers/receive_message_chain.rb @@ -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 diff --git a/lib/rspec/mocks/message_expectation.rb b/lib/rspec/mocks/message_expectation.rb index fd0cbe48a..dc72c1827 100644 --- a/lib/rspec/mocks/message_expectation.rb +++ b/lib/rspec/mocks/message_expectation.rb @@ -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 @@ -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. # @@ -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 diff --git a/spec/rspec/mocks/and_invoke_spec.rb b/spec/rspec/mocks/and_invoke_spec.rb new file mode 100644 index 000000000..cafa16d3e --- /dev/null +++ b/spec/rspec/mocks/and_invoke_spec.rb @@ -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 diff --git a/spec/rspec/mocks/matchers/receive_message_chain_spec.rb b/spec/rspec/mocks/matchers/receive_message_chain_spec.rb index f583201f1..3b4be6552 100644 --- a/spec/rspec/mocks/matchers/receive_message_chain_spec.rb +++ b/spec/rspec/mocks/matchers/receive_message_chain_spec.rb @@ -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 } diff --git a/spec/rspec/mocks/multiple_invoke_handler_spec.rb b/spec/rspec/mocks/multiple_invoke_handler_spec.rb new file mode 100644 index 000000000..c4757f02d --- /dev/null +++ b/spec/rspec/mocks/multiple_invoke_handler_spec.rb @@ -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