Code Reading: Expectations in RSpec 3.0

If you've downloaded the RSpec 3.0 beta, you may have noticed that the should method now triggers a deprecation warning:

Using should from rspec-expectations' old :should syntax without explicitly enabling the syntax is deprecated. Use the new :expect syntax or explicitly enable :should instead.

The expect syntax has been included since RSpec 2.11, released in July 2012:

# rather than:
foo.should == bar

# ...use:
expect(foo).to eq(bar)

RSpec core member @myronmarston explained in 2012 that the new syntax allows RSpec to avoid monkey-patching the Ruby Object class. Monkey-patching Object was the source of a number of cryptic errors. In fact, one of the main goals for RSpec 3.0 has been a "zero monkey-patching mode"–a version of RSpec which does not add methods like describe and should to every object.

This post examines how RSpec avoids monkey-patching by looking at should and expect specifically.

rspec-core vs. rspec-expectations

In a previous post I explained what happens the command-line executable rspec is run:

  1. All spec files are loaded
  2. As they are initialized, each example group in each spec file registers itself with the singleton
  3. #run is called on each of the example groups, which in turn calls #run on all of the group's individual examples (recall that each Example corresponds to an it block).

But this isn't the whole story. Example#run merely executes its it block within the context of its example group. Any exceptions the block might raise are caught and reported as failures. But Example doesn't provide a mechanism for raising these exceptions.

That is, Example doesn't define should, expect, or any other expectation methods. In fact, these methods aren't defined in rspec-core at all–they're in a separate gem called rspec-expectations. Each gem has a distinct set of responsibilities:

Matchers and expectations

rspec-expectations contains two kinds of classes: matchers and expectations.

  1. Matchers are objects that respond to the #matches? and #failure_message methods.
  2. Expectations specify the subject under test. They do so by using methods like expect(...).to.

The documentation in expectations.rb summarizes what rspec-expectations does perfectly:

When expect(...).to is invoked with a matcher, it turns around and calls matcher.matches?(<object wrapped by expect>).

In essence, this is all that should and expect(...).to really do.

How RSpec decides whether to use should or expect

rspec-core autoloads the rspec-expectations gem. Loading rspec-expectations in turn requires many other files. One of those files contains the RSpec::Matchers::Configuration class. This class is used to configure how rspec-expecations behaves. For example, it provides an interface for turning off color output for expectation failures.

The configuration class also allows users to specify whether to use should, expect, or both. It does so via the RSpec::Expectations::Syntax module, which responds to methods like #enable_should and #disable_should.

A quick look at #enable_should reveals the monkey-patching @myronmarston was referring to in his blog post–the method defines #should as a method on Object, whereas #expect is defined on the RSpec::Matchers module. The RSpec::Matchers module is autoloaded in rspec-core, thus allowing developers to simply write expect, and not RSpec::Matchers.expect, in their spec files.

should vs. expect

Aside from the fact that #should and #expect are defined on different hosts, their implementations are very similar. Both eventually call ::Expectations::PositiveExpectationHandler or NegativeExpectationHandler #handle_matcher. These check whether the supplied matcher #matches? the subject of the test. If it doesn't, the handler raises an ExpectationNotMetError.

The main difference between the two is that the #expect method returns an instance of ExpectationTarget. ExpectationTarget#to takes a matcher argument and in turn calls #match? on the expectation target. The #should method does this all in a single step.

The RSpec team's drive to eliminate monkey-patching is admirable. BDD frameworks like Kiwi would benefit from similar efforts to eliminate objc/runtime.h hacks and preprocessor macros.

Note: If you found this post on RSpec useful, consider helping me write more of these posts, by supporting me on Patreon.

More on RSpec