From 623e701f49d9f838d5b5102c9c6784823d7497f2 Mon Sep 17 00:00:00 2001 From: Xavier Shay Date: Tue, 1 Oct 2013 18:01:51 -0700 Subject: [PATCH] Provide object_double to create verifying doubles from object instances. --- Changelog.md | 2 + features/.nav | 1 + .../verifying_doubles/object_doubles.feature | 65 +++++++++++++++++ lib/rspec/mocks/example_methods.rb | 32 +++++++-- lib/rspec/mocks/method_reference.rb | 2 +- ...odule_reference.rb => object_reference.rb} | 21 ++++++ lib/rspec/mocks/verifying_double.rb | 29 ++++++-- spec/rspec/mocks/verifying_double_spec.rb | 72 +++++++++++++++++++ 8 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 features/verifying_doubles/object_doubles.feature rename lib/rspec/mocks/{module_reference.rb => object_reference.rb} (63%) diff --git a/Changelog.md b/Changelog.md index d8099ac3f..dfd02d16b 100644 --- a/Changelog.md +++ b/Changelog.md @@ -29,6 +29,8 @@ Enhancements: ported from `rspec-fire` (Xavier Shay). * `as_null_object` on a verifying double only responds to defined methods (Xavier Shay). +* Provide `object_double` to create verified doubles of specific object + instances (Xavier Shay). * Improved performance of double creation, particularly those with many attributes. (Xavier Shay) * Default value of `transfer_nested_constants` option for constant stubbing can diff --git a/features/.nav b/features/.nav index b90ebd9ef..40c12a3fb 100644 --- a/features/.nav +++ b/features/.nav @@ -25,6 +25,7 @@ - verifying_doubles: - introduction.feature - class_doubles.feature + - object_doubles.feature - dynamic_classes.feature - outside_rspec: - configuration.feature diff --git a/features/verifying_doubles/object_doubles.feature b/features/verifying_doubles/object_doubles.feature new file mode 100644 index 000000000..62ce7f1e8 --- /dev/null +++ b/features/verifying_doubles/object_doubles.feature @@ -0,0 +1,65 @@ +Feature: Using an object double + + `object_double` can be used to create a double from an existing "template" + object, from which it verifies that any stubbed methods on the double also + exist on the template. This is useful for objects that are readily + constructable, but may have far-reaching side-effects such as talking to a + database or external API. In this case, using a double rather than the real + thing allows you to focus on the communication patterns of the object's + interface without having to worry about accidentally causing side-effects. + Object doubles can also be used to verify methods defined on an object using + `method_missing`, which is not possible with `instance_double`. + + In addition, `object_double` can be used with specific constant values, as + shown below. This is for niche situations, such as when dealing with + singleton objects. + + Scenario: doubling an existing object + Given a file named "spec/user_spec.rb" with: + """ruby + class User + # Don't want to accidentally trigger this! + def save; sleep 100; end + end + + def save_user(user) + "saved!" if user.save + end + + describe '#save_user' do + it 'renders message on success' do + user = object_double(User.new, :save => true) + expect(save_user(user)).to eq("saved!") + end + end + """ + When I run `rspec spec/user_spec.rb` + Then the examples should all pass + + + Scenario: doubling a constant object + Given a file named "spec/email_spec.rb" with: + """ruby + require 'logger' + + module MyApp + LOGGER = Logger.new("myapp") + end + + class Email + def self.send_to(recipient) + MyApp::LOGGER.info("Sent to #{recipient}") + # other emailing logic + end + end + + describe Email do + it 'logs a message when sending' do + logger = object_double("MyApp::LOGGER", :info => nil).as_stubbed_const + Email.send_to('hello@foo.com') + expect(logger).to have_received(:info).with("Sent to hello@foo.com") + end + end + """ + When I run `rspec spec/email_spec.rb` + Then the examples should all pass diff --git a/lib/rspec/mocks/example_methods.rb b/lib/rspec/mocks/example_methods.rb index 640b486af..67f540b27 100644 --- a/lib/rspec/mocks/example_methods.rb +++ b/lib/rspec/mocks/example_methods.rb @@ -1,4 +1,4 @@ -require 'rspec/mocks/module_reference' +require 'rspec/mocks/object_reference' module RSpec module Mocks @@ -42,7 +42,7 @@ def double(*args) # allowed to be stubbed. In all other ways it behaves like a # [double](double). def instance_double(doubled_class, *args) - declare_verifying_double(InstanceVerifyingDouble, doubled_class, *args) + declare_instance_or_class_double(InstanceVerifyingDouble, doubled_class, *args) end # @overload class_double(doubled_class) @@ -56,7 +56,27 @@ def instance_double(doubled_class, *args) # allowed to be stubbed. In all other ways it behaves like a # [double](double). def class_double(doubled_class, *args) - declare_verifying_double(ClassVerifyingDouble, doubled_class, *args) + declare_instance_or_class_double(ClassVerifyingDouble, doubled_class, *args) + end + + # @overload object_double(object_or_name) + # @overload object_double(object_or_name, stubs) + # @param object_or_name [String, Object] + # @param stubs [Hash] (optional) hash of message/return-value pairs + # @return ObjectVerifyingDouble + # + # Constructs a test double against a specific object. Only instance + # methods on the object are allowed to be stubbed. If a String argument + # is provided, it is assumed to reference a constant object which is used + # for verification. In all other ways it behaves like a [double](double). + def object_double(object_or_name, *args) + ref = if object_or_name.is_a?(String) + ModuleReference.new(object_or_name) + else + ObjectReference.new(object_or_name) + end + + declare_verifying_double(ObjectVerifyingDouble, ref, *args) end # Disables warning messages about expectations being set on nil. @@ -157,9 +177,13 @@ def self.included(klass) private - def declare_verifying_double(type, constant_or_name, *args) + def declare_instance_or_class_double(type, constant_or_name, *args) ref = ModuleReference.new(constant_or_name) + declare_verifying_double(type, ref, *args) + end + + def declare_verifying_double(type, ref, *args) if RSpec::Mocks.configuration.verify_doubled_constant_names? && !ref.defined? diff --git a/lib/rspec/mocks/method_reference.rb b/lib/rspec/mocks/method_reference.rb index 9a1e3040a..ce8c2b429 100644 --- a/lib/rspec/mocks/method_reference.rb +++ b/lib/rspec/mocks/method_reference.rb @@ -77,7 +77,7 @@ def find_method(m) end # @private - class ClassMethodReference < MethodReference + class ObjectMethodReference < MethodReference private def method_implemented?(m) m.respond_to?(@method_name) diff --git a/lib/rspec/mocks/module_reference.rb b/lib/rspec/mocks/object_reference.rb similarity index 63% rename from lib/rspec/mocks/module_reference.rb rename to lib/rspec/mocks/object_reference.rb index 6b8faa970..b87bb79f4 100644 --- a/lib/rspec/mocks/module_reference.rb +++ b/lib/rspec/mocks/object_reference.rb @@ -1,6 +1,27 @@ module RSpec module Mocks + # An abstraction in front of objects so that non-loaded objects can be + # worked with. The null case is for concrete objects that are always + # loaded. See `ModuleReference` for an example of non-loaded objects. + class ObjectReference + def initialize(object) + @object = object + end + + def name + @object.to_s + end + + def defined? + true + end + + def when_loaded + yield @object + end + end + # Provides a consistent interface for dealing with modules that may or may # not be defined. # diff --git a/lib/rspec/mocks/verifying_double.rb b/lib/rspec/mocks/verifying_double.rb index 19a23f296..e6040c946 100644 --- a/lib/rspec/mocks/verifying_double.rb +++ b/lib/rspec/mocks/verifying_double.rb @@ -35,12 +35,9 @@ def __build_mock_proxy end end - # Similar to an InstanceVerifyingDouble, except that it verifies against - # public methods of the given class (i.e. the "class methods"). - # - # Module needs to be in the inheritance chain for transferring nested - # constants to work. - class ClassVerifyingDouble < Module + # An awkward module necessary because we cannot otherwise have + # ClassVerifyingDouble inherit from Module and still share these methods. + module ObjectVerifyingDoubleMethods include TestDouble include VerifyingDouble @@ -53,15 +50,33 @@ def initialize(doubled_module, *args) def __build_mock_proxy VerifyingProxy.new(self, @doubled_module, - ClassMethodReference + ObjectMethodReference ) end def as_stubbed_const(options = {}) + if @doubled_module.kind_of?(ObjectReference) + raise ArgumentError, + "Can not perform constant replacement with an object." + end + ConstantMutator.stub(@doubled_module.name, self, options) self end end + # Similar to an InstanceVerifyingDouble, except that it verifies against + # public methods of the given object. + class ObjectVerifyingDouble + include ObjectVerifyingDoubleMethods + end + + # Effectively the same as an ObjectVerifyingDouble (since a class is a type + # of object), except with Module in the inheritance chain so that + # transferring nested constants to work. + class ClassVerifyingDouble < Module + include ObjectVerifyingDoubleMethods + end + end end diff --git a/spec/rspec/mocks/verifying_double_spec.rb b/spec/rspec/mocks/verifying_double_spec.rb index bc93d67ac..0be381c57 100644 --- a/spec/rspec/mocks/verifying_double_spec.rb +++ b/spec/rspec/mocks/verifying_double_spec.rb @@ -3,6 +3,7 @@ class LoadedClass M = :m N = :n + INSTANCE = LoadedClass.new def defined_instance_method; end def self.defined_class_method; end @@ -122,6 +123,18 @@ def prevents(&block) prevents { o.undefined_method } end end + + it 'cannot be constructed with a non-module object' do + expect { + instance_double(Object.new) + }.to raise_error(/Module or String expected/) + end + + it 'can be constructed with a struct' do + o = instance_double(Struct.new(:defined_method), :defined_method => 1) + + expect(o.defined_method).to eq(1) + end end describe 'class doubles' do @@ -229,8 +242,67 @@ def prevents(&block) prevents { o.undefined_method } end end + + it 'cannot be constructed with a non-module object' do + expect { + class_double(Object.new) + }.to raise_error(/Module or String expected/) + end end + describe 'object doubles' do + it 'replaces an unloaded constant' do + o = object_double("LoadedClass::NOINSTANCE").as_stubbed_const + + expect(LoadedClass::NOINSTANCE).to eq(o) + + expect(o).to receive(:undefined_instance_method) + o.undefined_instance_method + end + + it 'replaces a constant by name and verifies instances methods' do + o = object_double("LoadedClass::INSTANCE").as_stubbed_const + + expect(LoadedClass::INSTANCE).to eq(o) + + prevents { expect(o).to receive(:undefined_instance_method) } + prevents { expect(o).to receive(:defined_class_method) } + prevents { o.defined_instance_method } + + expect(o).to receive(:defined_instance_method) + o.defined_instance_method + end + + it 'can create a double that matches the interface of any arbitrary object' do + o = object_double(LoadedClass.new) + + prevents { expect(o).to receive(:undefined_instance_method) } + prevents { expect(o).to receive(:defined_class_method) } + prevents { o.defined_instance_method } + + expect(o).to receive(:defined_instance_method) + o.defined_instance_method + end + + it 'does not allow transferring constants to an object' do + expect { + object_double("LoadedClass::INSTANCE"). + as_stubbed_const(:transfer_nested_constants => true) + }.to raise_error(/Cannot transfer nested constants/) + end + + it 'does not allow as_stubbed_constant for real objects' do + expect { + object_double(LoadedClass.new).as_stubbed_const + }.to raise_error(/Can not perform constant replacement with an object/) + end + + it 'is not a module' do + expect(object_double("LoadedClass::INSTANCE")).to_not be_a(Module) + end + end + + describe 'when verify_doubled_constant_names config option is set' do include_context "with isolated configuration"