Permalink
Browse files

Add test spies

* Adds have_received matcher
* Extends Proxy/MethodDouble/MessageExpectation
* Records all invocations on stubbed methods
  • Loading branch information...
1 parent e7417e1 commit e8cae54c46f20128ec16c4355f5898c48a8d2d91 Joe Ferris and Joël Quenneville committed with jferris Mar 15, 2013
@@ -0,0 +1,32 @@
+Feature: Spy on a stubbed method
+
+ You can use `have_received` to verify that a stubbed method was invoked,
+ rather than setting an expectation for it to be invoked beforehand.
+
+ Scenario: verify a stubbed method
+ Given a file named "verified_spy_spec.rb" with:
+ """ruby
+ describe "have_received" do
+ it "passes when the expectation is met" do
+ invitation = double('invitation', deliver: true)
+ invitation.deliver
+ invitation.should have_received(:deliver)
+ end
+ end
+ """
+ When I run `rspec verified_spy_spec.rb`
+ Then the examples should all pass
+
+ Scenario: fail to verify a stubbed method
+ Given a file named "failed_spy_spec.rb" with:
+ """ruby
+ describe "have_received" do
+ it "fails when the expectation is not met" do
+ invitation = double('invitation', deliver: true)
+ invitation.should have_received(:deliver)
+ end
+ end
+ """
+ When I run `rspec failed_spy_spec.rb`
+ Then the output should contain "expected: 1 time"
+ And the output should contain "received: 0 times"
@@ -0,0 +1,18 @@
+Feature: Spy on an unstubbed method
+
+ Using have_received on an unstubbed method will never pass, so issue a
+ helpful error message.
+
+ Scenario: fail to verify a stubbed method
+ Given a file named "failed_spy_spec.rb" with:
+ """ruby
+ describe "have_received" do
+ it "raises a helpful error for unstubbed methods" do
+ object = Object.new
+ object.object_id
+ object.should have_received(:object_id)
+ end
+ end
+ """
+ When I run `rspec failed_spy_spec.rb`
+ Then the output should contain "that method has not been stubbed"
@@ -72,6 +72,12 @@ def raise_only_valid_on_a_partial_mock(method)
"available on a partial mock object."
end
+ # @private
+ def raise_expectation_on_unstubbed_method(method)
+ __raise "#{intro} expected to have received #{method}, but that " +
+ "method has not been stubbed."
+ end
+
private
def intro
@@ -109,6 +109,20 @@ def hide_const(constant_name)
ConstantMutator.hide(constant_name)
end
+ # Spy on the given double after expected invocations have occurred.
+ #
+ # @param method_name [Symbol] name of the method expected to have been
+ # called.
+ #
+ # @example
+ #
+ # invitation = double('invitation', accept: true)
+ # user.accept_invitation(invitation)
+ # expect(invitation).to have_received(:accept)
+ def have_received(method_name)
+ HaveReceived.new(method_name)
+ end
+
private
def declare_double(declared_as, *args)
@@ -20,3 +20,4 @@
require 'rspec/mocks/serialization'
require 'rspec/mocks/any_instance'
require 'rspec/mocks/mutate_const'
+require 'rspec/mocks/have_received'
@@ -0,0 +1,59 @@
+module RSpec
+ module Mocks
+ class HaveReceived
+ CONSTRAINTS = %w(
+ exactly at_least at_most times any_number_of_times once twice with
+ )
+
+ def initialize(method_name)
+ @method_name = method_name
+ @constraints = []
+ end
+
+ def matches?(subject)
+ @expectation = expect(subject)
+ @expectation.expected_messages_received?
+ end
+
+ def does_not_match?(subject)
+ @expectation = expect(subject).never
+ @expectation.expected_messages_received?
+ end
+
+ def failure_message
+ generate_failure_message
+ end
+
+ def negative_failure_message
+ generate_failure_message
+ end
+
+ CONSTRAINTS.each do |expectation|
+ define_method expectation do |*args|
+ @constraints << [expectation, *args]
+ self
+ end
+ end
+
+ private
+
+ def expect(subject)
+ subject.__mock_expectation(@method_name) do |expectation|
+ apply_constraints_to expectation
+ end
+ end
+
+ def apply_constraints_to(expectation)
+ @constraints.each do |constraint|
+ expectation.send(*constraint)
+ end
+ end
+
+ def generate_failure_message
+ @expectation.generate_error
+ rescue RSpec::Mocks::MockExpectationError => error
+ error.message
+ end
+ end
+ end
+end
@@ -220,6 +220,12 @@ def add_negative_expectation(error_generator, expectation_ordering, expected_fro
expectation
end
+ # @private
+ def build_expectation(error_generator, expectation_ordering)
+ expected_from = caller(1)[0]
+ MessageExpectation.new(error_generator, expectation_ordering, expected_from, self)
+ end
+
# @private
def add_stub(error_generator, expectation_ordering, expected_from, opts={}, &implementation)
configure_method
@@ -121,6 +121,11 @@ def rspec_reset
__mock_proxy.reset
end
+ # @private
+ def __mock_expectation(method_name, &block)
+ __mock_proxy.build_expectation(method_name, &block)
+ end
+
private
def __mock_proxy
View
@@ -81,6 +81,24 @@ def add_negative_message_expectation(location, method_name, &implementation)
method_double[method_name].add_negative_expectation @error_generator, @expectation_ordering, location, &implementation
end
+ # @private
+ def build_expectation(method_name)
+ meth_double = method_double[method_name]
+ unless meth_double.stubs.any?
+ @error_generator.raise_expectation_on_unstubbed_method(method_name)
+ end
+
+ expectation = meth_double.build_expectation(
+ @error_generator,
+ @expectation_ordering
+ )
+
+ yield expectation
+
+ replay_received_message_on expectation
+ expectation
+ end
+
# @private
def add_stub(location, method_name, opts={}, &implementation)
method_double[method_name].add_stub @error_generator, @expectation_ordering, location, opts, &implementation
@@ -120,6 +138,7 @@ def record_message_received(message, *args, &block)
# @private
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)
@@ -200,6 +219,15 @@ def find_matching_method_stub(method_name, *args)
def find_almost_matching_stub(method_name, *args)
method_double[method_name].stubs.find {|stub| stub.matches_name_but_not_args(method_name, *args)}
end
+
+ def replay_received_message_on(expectation)
+ @messages_received.each do |(method_name, args, block)|
+ if expectation.matches?(method_name, *args)
+ expectation.invoke(nil)
+ end
+ end
+ end
+
end
end
end
@@ -0,0 +1,107 @@
+require 'spec_helper'
+
+module RSpec
+ module Mocks
+ describe HaveReceived do
+ context "matches?" do
+ it "returns true when an expectation is met" do
+ double = double_with_met_expectation(:expected_method)
+ result = have_received(:expected_method).matches?(double)
+ expect(result).to be_true
+ end
+
+ it "returns false when the expectation is not met" do
+ double = double_with_unmet_expectation(:expected_method)
+ result = have_received(:expected_method).matches?(double)
+ expect(result).to be_false
+ end
+ end
+
+ context "does_not_match?" do
+ it "returns true when the method is never called" do
+ double = double_with_unmet_expectation(:expected_method)
+ result = have_received(:expected_method).does_not_match?(double)
+ expect(result).to be_true
+ end
+
+ it "returns false when the method is called" do
+ double = double_with_met_expectation(:expected_method)
+ result = have_received(:expected_method).does_not_match?(double)
+ expect(result).to be_false
+ end
+ end
+
+ context "failure_message" do
+ it "includes the failed expectation" do
+ double = double_with_unmet_expectation(:expected_method)
+ matcher = have_received(:expected_method)
+ matcher.matches?(double)
+ message = matcher.failure_message
+ expect(message).to include('expected: 1 time')
+ end
+ end
+
+ context "negative_failure_message" do
+ it "includes the failed expectation" do
+ double = double_with_met_expectation(:expected_method)
+ matcher = have_received(:expected_method)
+ matcher.does_not_match?(double)
+ message = matcher.negative_failure_message
+ expect(message).to include('expected: 0 times')
+ expect(message).to include('received: 1 time')
+ end
+ end
+
+ context "with" do
+ it "matches when the given arguments match" do
+ double =
+ double_with_met_expectation(:expected_method, :expected, :args)
+ matcher = have_received(:expected_method).with(:expected, :args)
+ result = matcher.matches?(double)
+ expect(result).to be_true
+ end
+
+ it "doesn't match when the given arguments don't match" do
+ double = double_with_met_expectation(:expected_method, :unexpected)
+ matcher = have_received(:expected_method).with(:expected, :args)
+ result = matcher.matches?(double)
+ expect(result).to be_false
+ end
+ end
+
+ context "count constraint" do
+ HaveReceived::CONSTRAINTS.each do |constraint|
+ it "delegates #{constraint} to the expectation" do
+ double = double('double', some_method: true)
+ expectation =
+ double('message_expectation', expected_messages_received?: true)
+ double.
+ should_receive(:__mock_expectation).
+ with(:some_method).
+ and_yield(expectation).
+ and_return(expectation)
+ expectation.should_receive(constraint).with(:expected, :args)
+
+ have_received(:some_method).
+ send(constraint, :expected, :args).
+ matches?(double)
+ end
+ end
+ end
+
+ def double_with_met_expectation(method_name, *args)
+ double = double_with_unmet_expectation(method_name)
+ double.send(method_name, *args)
+ double
+ end
+
+ def double_with_unmet_expectation(method_name)
+ double('double', method_name => true)
+ end
+
+ def have_received(method_name)
+ HaveReceived.new(method_name)
+ end
+ end
+ end
+end
@@ -730,5 +730,27 @@ def add_call
end
end
+ describe '__mock_expectation' do
+ it 'returns a met expectation when expected messages are received' do
+ double = double('double', some_method: true)
+ double.some_method
+ expectation = double.__mock_expectation(:some_method) {}
+ expect(expectation.expected_messages_received?).to be_true
+ end
+
+ it 'returns an unmet expectation when expected messages are not received' do
+ double = double('double', some_method: true, other_method: true)
+ double.other_method
+ expectation = double.__mock_expectation(:some_method) {}
+ expect(expectation.expected_messages_received?).to be_false
+ end
+
+ it 'raises for an unstubbed method' do
+ double = double('double')
+ expect { double.__mock_expectation(:some_method) {} }.
+ to raise_error(MockExpectationError, /some_method.*stubbed/)
+ end
+ end
+
end
end

4 comments on commit e8cae54

Cool!

Contributor

alindeman replied May 18, 2013

We're working to ship a 2.14.rc1 with this feature soon.

Awesome!

Please sign in to comment.