RSpec's New Expectation Syntax
Myron Marston
Jun 15, 2012RSpec 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[^foot1], and people have been occasionally surprised by
the fact that should !=
does not work as they might expect[^foot2].
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[^foot_3] 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!
[^foot_1]: As Mislav reports, when warnings are turned on, you can get a “Useless use of == in void context” warning.
[^foot_2]: On ruby 1.8, x.should != y
is syntactic sugar for !(x.should == y)
and RSpec has no way to distinguish should ==
from should !=
. On 1.9, we can distinguish between them (since !=
can now be defined as a separate method), but it would be confusing to support it on 1.9 but not on 1.8, so we decided to just raise an error instead.
[^foot_3]: It was originally added over 3 years ago!