RSpec's New Expectation Syntax

Myron Marston

RSpec has featured a readable english-like syntax for setting expectations for a long time:

foo . should eq ( bar ) foo . should_not eq ( bar )

RSpec 2.11 will include a new variant to this syntax:

expect ( foo ). to eq ( bar ) expect ( foo ). not_to eq ( bar )

There are a few things motivating this new syntax, and I wanted to blog about it to spread awareness.

Delegation Issues

Between method_missing , BasicObject and the standard library's delegate , ruby has very rich tools for building delegate or proxy objects. Unfortunately, RSpec's should syntax, as elegantly as it reads, is prone to producing weird, confusing failures when testing delegate/proxy objects.

Consider a simple proxy object that subclasses BasicObject :

# fuzzy_proxy.rb class FuzzyProxy < BasicObject def initialize ( target ) @target = target end def fuzzy? true end def method_missing ( * args , & block ) @target . __send__ ( * args , & block ) end end

Simple enough; it defines a #fuzzy? predicate, and delegates all other method calls to the target object.

Here's a simple spec to test its fuzziness:

# fuzzy_proxy_spec.rb describe FuzzyProxy do it 'is fuzzy' do instance = FuzzyProxy . new ( :some_object ) instance . should be_fuzzy end end

Surprisingly, this fails:

1) FuzzyProxy is a fuzzy proxy Failure/Error: instance.should be_fuzzy NoMethodError: undefined method `fuzzy?' for :some_object:Symbol # ./fuzzy_proxy.rb:11:in `method_missing' # ./fuzzy_proxy_spec.rb:6:in `block (2 levels) in <top (required)>' Finished in 0.01152 seconds 1 example, 1 failure

The problem is that rspec-expectations defines should on Kernel , and BasicObject does not include Kernel …so instance.should triggers method_missing and gets delegated to the target object. The result is actually :some_object.should be_fuzzy which is clearly false (or rather, a NoMethodError ).

It gets even more confusing when using delegate in the standard library. It selectively includes some of Kernel 's methods…which means that if rspec-expectations gets loaded before delegate , should will work properly on delegate objects, but if delegate is loaded first, it will proxy the should calls just like in our FuzzyProxy example above.

The underlying problem is RSpec's should syntax: for should to work properly, it must be defined on every object in the system… but RSpec does not own every object in the system and cannot ensure that it always works consistently. As we've seen, it doesn't work as RSpec expects on proxy objects. Note that this isn't just a problem with RSpec; it's a problem with minitest/spec's must_xxx syntax as well.

The solution we came up with is the new expect syntax:

# fuzzy_proxy_spec.rb describe FuzzyProxy do it 'is fuzzy' do instance = FuzzyProxy . new ( :some_object ) expect ( instance ). to be_fuzzy end end

This does not rely on any methods being present on all objects in the system, and thus avoids the underlying problem altogether.

(Almost) All Matchers Are Supported

The new expect syntax looks different from the old should syntax, but under the covers, it's essentially the same. You pass a matcher to the #to method, and it fails the example if it does not match.

All matchers are supported, with an important exception: the expect syntax does not directly support the operator matchers.

# rather than: foo . should == bar # ...use: expect ( foo ). to eq ( bar )

While operator matchers are intuitive to use, they require special handling in RSpec for them to work right, due to Ruby's precedence rules. Furthermore, should == generates a ruby warning , and people have been occasionally surprised by the fact that should != does not work as they might expect .

The new syntax affords us the chance to make a clean break from the inconsistencies of the operator matchers without the risk of breaking existing test suites, so we decided not to support operator matchers with the new syntax. Here's a listing of each of the old operator matchers (used with should ), and their expect equivalent:

foo . should == bar expect ( foo ). to eq ( bar ) "a string" . should_not =~ /a regex/ expect ( "a string" ). not_to match ( /a regex/ ) [ 1 , 2 , 3 ]. should =~ [ 2 , 1 , 3 ] expect ([ 1 , 2 , 3 ]). to match_array ([ 2 , 1 , 3 ])

You may have noticed I didn't list the comparison matchers (e.g. x.should < 10 )–that's because they work but have never been recommended. Who says "x should less than 10"? They were always intended to be used with be , which both reads better and continues to work:

foo . should be < 10 foo . should be <= 10 foo . should be > 10 foo . should be >= 10 expect ( foo ). to be < 10 expect ( foo ). to be <= 10 expect ( foo ). to be > 10 expect ( foo ). to be >= 10

Unification of Block vs. Value Syntaxes

expect has actually been available in RSpec for a long time in a limited form, as a more-readable alternative for block expectations:

# rather than: lambda { do_something }. should raise_error ( SomeError ) # ...you can do: expect { something }. to raise_error ( SomeError )

Before RSpec 2.11, expect would not accept any normal arguments, and could not be used for value expectations. With the changes in 2.11, it's nice to have the unity of the same syntax for both kinds of expectations.

Configuration Options

By default, both the should and expect syntaxes are available. However, if you want to use only one syntax or the other, you can configure RSpec:

# spec_helper.rb RSpec . configure do | config | config . expect_with :rspec do | c | # Disable the `expect` sytax... c . syntax = :should # ...or disable the `should` syntax... c . syntax = :expect # ...or explicitly enable both c . syntax = [ :should , :expect ] end end

For example, if you're starting a new project, and you want to ensure only expect is used for consistency, you can disable should entirely. When one of the syntaxes is disabled, the corresponding method will simply be undefined.

In the future, we plan to change the defaults so that only expect is available unless you explicitly enable should . We may do this as soon as RSpec 3.0, but we want to give users plenty of time to get acquianted with it.

Let us know what you think!

Please enable JavaScript to view the comments powered by Disqus.