Code Reading: Message Stubbing in RSpec
RSpec isn't just nested context blocks and expect(...).to
expectations; it also includes a powerful mocking library: rspec-mocks
.
Take the following spec, which stubs Car#honk
:
# car_spec.rb
class Car
def honk
puts 'Honk!!'
end
end
describe Car do
let(:car) { described_class.new }
it 'receives honk' do
# Stubs the `#honk` message and returns `nil`.
# The actual implementation, which prints "Honk!!",
# is never executed.
allow(car).to receive(:honk)
car.honk
end
end
How does RSpec achieve this? Let's examine the source code step-by-step.
The allow
syntax
allow
is defined in RSpec::Mocks::Syntax
. As with the should
and expect(...).to
syntax in rspec-expectations
, RSpec::Mocks::Configuration
allows a user to enable or disable their preferred message stubbing syntax:
RSpec.configure do |config|
config.mock_with :rspec do |c|
# Both `#stub` and `#allow` are enabled by default
# c.syntax = [:should, :expect]
# Only enable `#allow`.
# The old syntax, `car.stub :honk`, raises an exception
c.syntax = [:expect]
end
end
As discussed in my slides for "RSpec 3.0: Under the Covers", the new syntax is preferred because, unlike #stub
, it does not monkey-patch the Object
class. Instead, #allow
is defined on RSpec::Mocks::ExampleMethods
, which is included in the RSpec namespace.
The allow
implementation
#allow
returns an AllowanceTarget
that calls the #setup_allowance
method on whatever matcher is passed to #to
–usually the receive
matcher:
# Calls `#setup_allowance` on `::Mocks::Matchers::Receive`
allow(car).to receive(:honk)
The extra layer of indirection in the allow(...).to
syntax provides some flexibility. For example, AllowanceTarget
instances do not accept negation via #not_to
or #to_not
. That is, allow(...).not_to
, which doesn't make very much sense, raises a NegationUnsupportedError
.
The receive
matcher and message stubbing: proxies in space
RSpec implements message stubbing by maintaining a hash of proxies. The keys in the hash are the object IDs of the objects being proxied to (recall that you can get a unique ID for any Ruby object by using the #__id__
method).
Each proxy maintains an array of MethodDouble
objects, which are responsible for intercepting individual messages.
These proxies are stored in instances of RSpec::Mocks::Space
. In order to be integrated into testing frameworks like rspec-core
, Space
needs to be integrated in three steps during each example:
RSpec::Mocks.setup
: This method initializes the space. It must be called before each example.RSpec::Mocks.verify
: If, in addition to simply stubbing the messages, expectations were made that certain messages be sent–i.e.:expect(...).to receive(:message)
–those expectations are verified in this step. This method should be called at the end of each example.RSpec::Mocks.teardown
: This removes all stubs and removes the current state. This method must be called at the end of each example, afterSpace.verify
.
When the receive
matcher is sent the #setup_allowance
message, it calls #add_stub
on the proxy, passing the original method name as a argument. This aliases the original method to be stubbed to a new method called "obfuscated_by_rspec_mocks__#{method}"
. In its place, it defines a new method with the original method name. This doppelganger method calls #proxy_method_invoked
on the MethodDouble
that corresponds to the stubbed method.
Bringing this back to our example:
# 1. `receive(:honk)` finds the proxy for the `car` object in
# the current space. It then adds a message double for `#honk`
# on that proxy.
# 2. `receive` aliases `#honk` to a new method,
# `#obfuscated_by_rspec_mocks__honk`.
# 3. `#honk` itself is replaced with a new method of the same name.
# This new method calls `#proxy_method_invoked` on the
# message double for `#honk`.
allow(car).to receive(:honk)
After the example is finished, Mocks.verify
checks the number of times each proxy's method doubles were invoked, and compares it to any expectations that may have been made. Finally, Mocks.teardown
restores all the original methods, returning #obfuscated_by_rspec_mocks__honk
to its original name of #honk
. All proxies and message doubles are removed.
No black magic, just good design
Pretty neat, huh? I expected to see more black magic, but under the covers it was just a simple method alias and some good object-oriented design. It's thanks to this design that RSpec can trivially support the following:
# car_spec.rb
class Car
def honk
puts 'Honk!!'
end
end
describe Car do
let(:car) { described_class.new }
it 'receives honk' do
# Stubs the message like before, but in addition also calls
# the original implementation of `#honk`, thereby printing
# "Honk!!". All this does is call the aliased method,
# `#obfuscated_by_rspec_mocks__honk`!
allow(car).to receive(:honk).and_call_original
car.honk
end
end