Composing Matchers

Even on their own, matchers are powerful tools for your tests. But they really shine when you compose them with other matchers to specify exactly what you expect (and nothing more). The result is more robust tests and fewer false failures.

There are a few different ways to compose matchers:

  • Pass one matcher directly into another
  • Embed matchers in Array and Hash data structures
  • Combine matchers with logical and/or operators

Before we consider these three cases, let’s look at how matchers determine whether or not the subject matches.

How Matchers Match Objects

Matchers build on top of one of Ruby’s standard protocols in order to provide composability: the humble === method. This method, often called “three-quals” or “case equality,” defines a category to which other objects may (or may not) belong. Ruby’s built-in regular expressions, ranges, and classes define this operator, and you can add it to your own objects as well.

Let’s see how case equality works in an IRB session:

 >>​ ​/^[warn] /​ === ​'[warn] Disk space low'
 => true
 >>​ ​/^[warn] /​ === ​'[error] Out of memory'
 => false
 >>​ (1..10) === 5
 => true
 >>​ (1..10) === 15
 => false

We’re playing with === here just to get a feel for how Ruby (and RSpec) use it internally. Most of the time, you won’t call it directly from production code. Instead, Ruby will call it for you inside each when clause of a case expression.

Just to drive home the point that matchers are plain old Ruby objects that implement ===, here’s what a matcher would look like inside a case expression:

 >>​ ​def​ describe_value(value)
 >>​ ​case​ value
 >>​ ​when​ be_within(0.1).of(Math::PI) ​then​ ​'Pi'
 >>​ ​when​ be_within(0.1).of(2 * Math::PI) ​then​ ​'Double Pi'
 >>​ ​end
 >>​ ​end
 => :describe_value
 >>​ describe_value(3.14159)
 => "Pi"
 >>​ describe_value(6.28319)
 => "Double Pi"

RSpec expectations perform the same check internally that Ruby’s case statement does: they call === on the object you pass in. That object can be anything—including another matcher.

Passing One Matcher Into Another

It may not be obvious why you’d need to pass a matcher to another matcher. Let’s say you expect a particular array to start with a value that’s near π. With RSpec, you can pass the be_within(0.1).of(Math::PI) matcher into the start_with matcher to specify this behavior:

 >>​ numbers = [3.14159, 1.734, 4.273]
 => [3.14159, 1.734, 4.273]
 >>​ ​expect​(numbers).to start_with( be_within(0.1).of(Math::PI) )
 => true

It just works! Now, let’s see what the message looks like when the expectation fails:

 >>​ ​expect​([]).to start_with( be_within(0.1).of(Math::PI) )
 RSpec::Expectations::ExpectationNotMetError: expected [] to start with be ↩
 within 0.1 of 3.141592653589793
  backtrace truncated

Unfortunately, the failure message is the grammatically awkward “expected [] to start with be within 0.1 of π.” Luckily, RSpec provides aliases (generally in the form of a noun phrase) for the built-in matchers that read much better in situations like these:

 >>​ ​expect​([]).to start_with( a_value_within(0.1).of(Math::PI) )
 RSpec::Expectations::ExpectationNotMetError: expected [] to start with a ↩
 value within 0.1 of 3.141592653589793
  backtrace truncated

Much better. This actually reads like English, and is much more intelligible: “expected [] to start with a value within 0.1 of π.”

The a_value_within matcher is an alias for be_within that acts identically, except for how it describes itself. RSpec provides a number of aliases like this for each of the built-in matchers.[78] As we’ll see in Defining Matcher Aliases, it’s trivial to define your own aliases as well.

Embedding Matchers in Array and Hash Data Structures

In addition to passing one matcher into another, you can also embed matchers within an array at any position, or within a hash in place of a value. RSpec will compare the corresponding items using ===. This technique works at any level of nesting. Let’s look at an example:

 presidents = [
  { ​name: ​​'George Washington'​, ​birth_year: ​1732 },
  { ​name: ​​'John Adams'​, ​birth_year: ​1735 },
  { ​name: ​​'Thomas Jefferson'​, ​birth_year: ​1743 },
 # ...
 ]
 expect​(presidents).to start_with(
  { ​name: ​​'George Washington'​, ​birth_year: ​a_value_between(1730, 1740) },
  { ​name: ​​'John Adams'​, ​birth_year: ​a_value_between(1730, 1740) }
 )

Here, we’re using a_value_between(1730, 1740) for the birth years of George Washington and John Adams instead of a specific number. Of course, we know their birth years were exactly 1732 and 1735, respectively. But not all your real-world values are going to be so precise. If you test for behavior more specific than what you actually need, or if you’re testing nondeterministic logic, your specs may break if the implementation changes slightly.

This ability to compose matchers—by passing them into one another, or by embedding them in data structures—lets you be as precise or as vague as you need to be. When you specify exactly the behavior you expect (and nothing more), your tests become less brittle.

Combining Matchers With Logical and/or Operators

There’s another way to combine matchers: compound matcher expressions. Every built-in matcher has two methods (and and or) that allow you to logically combine any two matchers into a compound matcher:

 alphabet = (​'a'​..​'z'​).to_a
 expect​(alphabet).to start_with(​'a'​).and end_with(​'z'​)
 
 stoplight_color = ​%w[ green red yellow ]​.sample
 expect​(stoplight_color).to eq(​'green'​).or eq(​'red'​).or eq(​'yellow'​)

As we see in the stoplight_color example, you can string matchers together into arbitrarily long chains. You can use the words and/or, or you can use the & and | operators:

 alphabet = (​'a'​..​'z'​).to_a
 expect​(alphabet).to start_with(​'a'​) & end_with(​'z'​)
 
 stoplight_color = ​%w[ green red yellow ]​.sample
 expect​(stoplight_color).to eq(​'green'​) | eq(​'red'​) | eq(​'yellow'​)

This syntax might seem really fancy and complex, but internally, it’s quite simple. These methods return a new matcher that wraps the two operands. The and matcher only succeeds if both of its operands match. The or matcher succeeds if either operand matches.

Poking around in IRB will give you a sense of how these work:

 >>​ start_with_a_and_end_with_z = start_with(​'a'​).and end_with(​'z'​)
 => #<RSpec::Matchers::BuiltIn::Compound::And:0x007f94dc83ba30
  @matcher_1=#<RSpec::Matchers::BuiltIn::StartWith:0x007f94dc82bd38
  @actual_does_not_have_ordered_elements=false, @expected="a">,
  @matcher_2=#<RSpec::Matchers::BuiltIn::EndWith:0x007f94dc82bc20
  @actual_does_not_have_ordered_elements=false, @expected="z">>

Here we’ve created a compound matcher, an instance of RSpec::Matchers::BuiltIn ::Compound::And, which keeps internal references to the original start_with and end_with matchers.

The new compound matcher works just like any other. It even provides its own error message, based on the messages of the underlying matchers:

 >>​ ​expect​([​'a'​, ​'z'​]).to start_with_a_and_end_with_z
 => true
 >>​ ​expect​([​'a'​, ​'y'​]).to start_with_a_and_end_with_z
 RSpec::Expectations::ExpectationNotMetError: expected ["a", "y"] to end ↩
 with "z"
  backtrace truncated
 >>​ ​expect​([​'b'​, ​'y'​]).to start_with_a_and_end_with_z
 RSpec::Expectations::ExpectationNotMetError: expected ["b", "y"] to ↩
 start with "a"
 
 ...and:
 
  expected ["b", "y"] to end with "z"
  backtrace truncated

Compound matchers are smart enough to show failure messages only for the bits that failed.

Like all matchers, compound matchers can be passed as arguments to other matchers:

 letter_ranges = [​'N to Z'​, ​'A to M'​]
 expect​(letter_ranges).to contain_exactly(
  a_string_starting_with(​'A'​) & ending_with(​'M'​),
  a_string_starting_with(​'N'​) & ending_with(​'Z'​)
 )

You can mix and match these techniques for composing matchers, all in one expectation. You can also nest them as deeply as you need. Here, we’re combining matchers with logical operators, then nesting those combinations into a collection matcher. This flexibility allows you to describe complex data precisely.

Now that you’ve seen how to combine matchers, let’s turn our attention to output.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset