As others have pointed out, it's not intuitive what
the order of arguments is for assert_equal(foo, bar)
. Which is
expected? Which is actual? I had to look it up.
Compare that to expect(bar).to eq(foo)
-- which is expected
and which is actual is immediately obvious. assert_equal
is one of the simplest assertions. It gets worse when you start
dealing with more complicated assertions like assert_in_delta
--
now you've got 3 arguments, and the order is non-intuitive. Compare
that to: expect(x).to be_within(d).of(y)
. The delta, actual and
expected values are all obvious.
With a matcher, you write one matches?
method, and you
get automatic negation (e.g. expect(x).not_to matcher
)
for free. With an xUnit style assert_xxx
method, you have to write a
separate method for it.
xUnit style assertion methods mix the match logic and the
failure message in a single method. The method is either
a no-op or it raises an error. Matchers are first class
objects that separate the match logic (in the form of a matches?
method) from the failure message (in the form of a
failure_message_for_should
method). This separation is useful
because it enables composability (see below) and makes it
easy for RSpec to provide a default failure message for common
cases. For example, with a custom matcher like this:
RSpec::Matchers.define :be_a_multiple_of do |factor|
match { |value| value % factor == 0 }
end
...RSpec automatically generates a readable failure message
based on the matcher name and argument. Here's the output
RSpec produces for an expression like expect(5).to be_a_multiple_of(3)
: expected 5 to be a multiple of 3
.
Some of RSpec's matchers are composable. (I'd like to improve things where they are all composable, but we're not there yet). You can pass a matcher as an argument to another matcher. For example:
expect {
record.save
}.to change { record.created_at }.from(nil).to(be_within(1.second).of(Time.now))
Here we're passing the be_within
matcher as an argument to the
change
matcher, to specify that we expected created_at
to be changed
to a value near Time.now
.
Another example:
expect(array).to include(match(/foo/), match(/bar/))
Here we expect an array to include 2 strings, one of which
matches the regex /foo/
and another that matches the regex
/bar/
.
assert_xyz
methods cannot easily be composed in the same fashion.
Due to the way matchers use a fluent interface, it is easy to
support slight varitions without needing a complex options hash.
For example, you can use the change
matcher in multiple flexible ways:
expect { foo.x }.to change { foo.y }
expect { foo.x }.to change { foo.y }.by(3)
expect { foo.x }.to change { foo.y }.from(10).to(20)
In the first, we are specifing that foo.y
should change but we don't
care how it changes. In the second, we specify a relative change --
foo.y
should increase by 3. In the last case, we are specifying
the exact before/after values.
Another example:
expect(x).to be_within(0.1).of(y)
expect(x).to be_within(5).percent_of(y)
be_within(x).percent_of(y)
is a relatively recent addition.
The nice thing is it fits right in, reads untuitively, and
did not require significant refactorings or any API breakages.
Since matchers are first class objects, they can be used in other
contexts. For example, rspec-mocks supports passing a matcher
to with
when setting a message expectation:
# This is the new message expectation syntax shipping in 2.14.
# It works the same for `should_receive`.
expect(collaborator).to receive(:do_something).with(some_matcher(some_argument))
I've found other uses for matchers as well; for example, on my current
project we're using Sequel and for some unit tests I added a test
harness that uses Sequel's mock database adapter and a simple
stub_query
helper method to stub DB queries:
stub_query(include("WHERE x > 3")) do
[
{ id: 3, other_column: 2 },
{ id: 5, other_column: 1 }
]
end
The matcher is used to match the query when it is performed (asserting that the SQL string includes "WHERE x > 3") and the return value of the block is used as the query result.
With xUnit-style assert
methods, there's not a similar
way (that I can think of) to use them in other contexts.
All that said, there are some downsides to using RSpec's matchers:
- It creates more objects (
expect()
creates an object, and the matcher method creates the matcher object), which leads to more garbage for the GC to cleanup. - It's an extra layer of abstraction that
assert_xyz
lacks.assert
methods are simpler. RSpec's approach adds cognative load to understanding what's going on.
Overall, I think it largely comes down to personal taste, and what you value the most. Both approaches have their merits. If you like the features rspec-core provides but prefer not to use rspec-expectations, you can configure it to use Minitest's assertions instead:
RSpec.configure do |rspec|
rspec.expect_with :stdlib
end