New in RSpec 3: Composable Matchers

Myron Marston

Jan 14, 2014

One of RSpec 3’s big new features is shipping 3.0.0.beta2: composable matchers. This feature supports more powerful, less brittle expectations, and opens up new possibilities.

An Example

In RSpec 2.x, I’ve written code like this on many occassions:

# background_worker.rb
class BackgroundWorker
  attr_reader :queue

  def initialize
    @queue = []
  end

  def enqueue(job_data)
    queue << job_data.merge(:enqueued_at => Time.now)
  end
end
# background_worker_spec.rb
describe BackgroundWorker do
  it 'puts enqueued jobs onto the queue in order' do
    worker = BackgroundWorker.new
    worker.enqueue(:klass => "Class1", :id => 37)
    worker.enqueue(:klass => "Class2", :id => 42)

    expect(worker.queue.size).to eq(2)
    expect(worker.queue[0]).to include(:klass => "Class1", :id => 37)
    expect(worker.queue[1]).to include(:klass => "Class2", :id => 42)
  end
end

In RSpec 3, composable matchers allow you to pass matchers as arguments (or nested within data structures passed as arguments) to other matchers allowing you to simplify specs like these:

# background_worker_spec.rb
describe BackgroundWorker do
  it 'puts enqueued jobs onto the queue in order' do
    worker = BackgroundWorker.new
    worker.enqueue(:klass => "Class1", :id => 37)
    worker.enqueue(:klass => "Class2", :id => 42)

    expect(worker.queue).to match [
      a_hash_including(:klass => "Class1", :id => 37),
      a_hash_including(:klass => "Class2", :id => 42)
    ]
  end
end

We’ve made sure the failure messages read well for cases like these, opting to use the description of the provided matcher rather than the inspect output. For example, if we “break” the implementation tested by this spec by commenting out the queue << ... line, it fails with:

1) BackgroundWorker puts enqueued jobs onto the queue in order
   Failure/Error: expect(worker.queue).to match [
     expected [] to match [(a hash including {:klass => "Class1", :id => 37}), (a hash including {:klass => "Class2", :id => 42})]
     Diff:
     @@ -1,3 +1,2 @@
     -[(a hash including {:klass => "Class1", :id => 37}),
     - (a hash including {:klass => "Class2", :id => 42})]
     +[]

   # ./spec/background_worker_spec.rb:19:in `block (2 levels) in <top (required)>'

Matcher aliases

As you may have noticed, the example above uses a_hash_including in place of include. RSpec 3 provides similar aliases for all of the built-in matchers that read better grammatically and provide a better failure message.

For example, compare this expectation and failure message:

x = "a"
expect { }.to change { x }.from start_with("a")
expected result to have changed from start with "a", but did not change

…to:

x = "a"
expect { }.to change { x }.from a_string_starting_with("a")
expected result to have changed from a string starting with "a",
but did not change

While a_string_starting_with is more verbose than start_with, it produces a failure message that actually reads well, so you don’t trip over odd grammatical constructs. We’ve provided one or more similar aliases for all of RSpec’s built-in matchers. We’ve tried to use consistent phrasing (generally “a [type of object] [verb]ing”) so they are easy to guess. You’ll see many examples below, and the RSpec 3 docs will have a full list.

There’s also a public API that makes it trivial to define your own aliases (either for RSpec’s built in matchers or for a custom matcher). Here’s the bit of code in rspec-expectations that provides the a_string_starting_with alias of start_with:

RSpec::Matchers.alias_matcher :a_string_starting_with, :start_with

Compound Matcher Expressions

Eloy Espinaco contributed a new feature that provides another way of combining matchers: compound and and or matcher expressions. For example, rather than writing this:

expect(alphabet).to start_with("a")
expect(alphabet).to end_with("z")

…you can combine these into one expectation:

expect(alphabet).to start_with("a").and end_with("z")

You can do the same with or. While less common, this is useful for expressing one of a valid list of values (e.g. when the exact value is indeterminite):

expect(stoplight.color).to eq("red").or eq("green").or eq("yellow")

I think this could particularly come in handy for expressing invariants using Jim Weirich’s rspec-given.

Compound matcher expressions can also be passed as an argument to another matcher:

expect(["food", "drink"]).to include(
  a_string_starting_with("f").and ending_with("d")
)

Note: in this example, ending_with is another alias for the end_with matcher.

Which matchers support matcher arguments?

In RSpec 3, we’ve updated many of the matchers to support receiving matchers as arguments, but not all of them do. In general, we updated all of the ones where we felt like it made sense. The ones that do not support matchers are those that have precise matching semantics that do not allow for a matcher argument. For example the eq matcher is documented as passing if and only if actual == expected. It doesn’t make sense for eq to support receiving a matcher argument[^foot_1].

I’ve compiled a list below of all the built-in matchers that support receiving matchers as arguments.

change

The by method of the change matcher can receive a matcher:

k = 0
expect { k += 1.05 }.to change { k }.by( a_value_within(0.1).of(1.0) )

You can also pass matchers to from or to:

s = "food"
expect { s = "barn" }.to change { s }.
  from( a_string_matching(/foo/) ).
  to( a_string_matching(/bar/) )

contain_exactly

contain_exactly is a new alias of match_array. The semantics are a bit more clear than match_array (now that match can match arrays, too, but match requires the ordering to match whereas match_array doesn’t). It also allows you to pass the array elements as individual arguments rather than being forced to pass a single array argument like match_array expects.

expect(["barn", 2.45]).to contain_exactly(
  a_value_within(0.1).of(2.5),
  a_string_starting_with("bar")
)

# ...which is the same as:

expect(["barn", 2.45]).to match_array([
  a_value_within(0.1).of(2.5),
  a_string_starting_with("bar")
])

include

include allows you to match against the elements of a collection, the keys of a hash, or against a subset of the key/value pairs in a hash:

expect(["barn", 2.45]).to include( a_string_starting_with("bar") )

expect(12 => "twelve", 3 => "three").to include( a_value_between(10, 15) )

expect(:a => "food", :b => "good").to include(
  :a => a_string_matching(/foo/)
)

match

In addition to matching a string against a regex or another string, match now works against arbitrary array/hash data structures, nested as deeply as you like. Matchers can be used at any level of that nesting:

hash = {
  :a => {
    :b => ["foo", 5],
    :c => { :d => 2.05 }
  }
}

expect(hash).to match(
  :a => {
    :b => a_collection_containing_exactly(
      an_instance_of(Fixnum),
      a_string_starting_with("f")
    ),
    :c => { :d => (a_value < 3) }
  }
)

raise_error

raise_error can accept a matcher for matching against the exception class or a matcher to match against the message, or both.

RSpec::Matchers.define :an_exception_caused_by do |cause|
  match do |exception|
    cause === exception.cause
  end
end

expect {
  begin
    "foo".gsub # requires 2 args
  rescue ArgumentError
    raise "failed to gsub"
  end
}.to raise_error( an_exception_caused_by(ArgumentError) )

expect {
  raise ArgumentError, "missing :foo arg"
}.to raise_error(ArgumentError, a_string_starting_with("missing"))

startwith and endwith

These are pretty self-explanatory:

expect(["barn", "food", 2.45]).to start_with(
  a_string_matching("bar"),
  a_string_matching("foo")
)

expect(["barn", "food", 2.45]).to end_with(
  a_string_matching("foo"),
  a_value < 3
)

throw_symbol

You can pass a matcher to throw_symbol to match against the accompanying argument:

expect {
  throw :pi, Math::PI
}.to throw_symbol(:pi, a_value_within(0.01).of(3.14))

yieldwithargs and yieldsuccessiveargs

Matchers can be used to specify the yielded arguments for these matchers:

expect { |probe|
  "food".tap(&probe)
}.to yield_with_args( a_string_starting_with("f") )

expect { |probe|
  [1, 2, 3].each(&probe)
}.to yield_successive_args( a_value < 2, 2, a_value > 2 )

Conclusion

This is one of the new features of RSpec 3 I’m most excited about and I hope you can see why. This should help make it easier to avoid writing brittle specs by enabling you to specify exactly what you expect (and nothing more).

[^foot_1]: You can, of course pass a matcher to eq, but it’ll treat it just like any other object: it’ll compare it to actual using ==, and, if that returns true (i.e. if it’s the same object), the expectation will pass.