Block Matchers

With all the expectations we’ve seen so far, we’ve passed regular Ruby objects into expect:

 expect​(3).to eq(3)

This is fine for checking properties of your data. But sometimes you need to check properties of a piece of code. For example, perhaps a certain piece of code is supposed to raise an exception. In this case, you can pass a block into expect:

 expect​ { ​raise​ ​'boom'​ }.to raise_error(​'boom'​)

RSpec will run the block and watch for the specific side effects you specify: exceptions, mutating variables, I/O, and so on.

Raising and Throwing

You’re likely familiar with raising Ruby exceptions to jump out of your running code and report an error to the caller. Ruby also has a related concept, throwing symbols, for jumping to other parts of your program.

RSpec provides matchers for both of these situations: the appropriately named raise_error and throw_symbol.

raise_error

First, let’s look at raise_error, also known as raise_exception. This matcher is very flexible, supporting multiple forms:

  • raise_error with no arguments matches if any error is raised.

  • raise_error(SomeErrorClass) matches if SomeErrorClass or a subclass is raised.

  • raise_error(’some message’) matches if an error is raised with a message exactly equal to a given string.

  • raise_error(/some regex/) matches if an error is raised with a message matching a given pattern.

You can combine these criteria if both the class and message are important, either via passing two arguments or by using a fluent interface:

  • raise_error(SomeErrorClass, "some message")
  • raise_error(SomeErrorClass, /some regex/)
  • raise_error(SomeErrorClass).with_message("some message")
  • raise_error(SomeErrorClass).with_message(/some regex/)

With any of these forms, you can pass in another RSpec matcher (such as a_string_starting_with for the message name) to control how the matching works. For example, the following expectation ensures the exception has its name attribute set:

 expect​ {
 'hello'​.world
 }.to raise_error(an_object_having_attributes(​name: :world​))

Checking properties of exceptions can get really fiddly. If you find yourself passing a complex nested composed matcher expression to raise_error, you’ll see a really long failure message in the output. To avoid this situation, you can instead pass a block into raise_error and move your logic there:

 expect​ { ​'hello'​.world }.to raise_error(NoMethodError) ​do​ |ex|
 expect​(ex.name).to eq(​:world​)
 end

There are a couple of gotchas with raise_error that can lead to false positives. First, raise_error (with no arguments) will match any error—and it can’t tell the difference between exceptions you did or did not mean to throw.

For example, if you rename a method but forget to update your spec, Ruby will throw a NoMethodError. An overzealous raise_error will swallow this exception, and your spec will pass even though it’s no longer exercising the method you mean to test!

Likewise, you might use raise_error(ArgumentError) to make sure one of your methods correctly raises this error. If you later make a breaking change to the method signature—such as adding an argument—but forget to update a caller, Ruby will throw the same error. Your spec will pass (because all it sees is the ArgumentError it’s expecting), but the code will still be broken.

We’ve actually run into this kind of false positive In RSpec itself.[86]

Never Check for a Bare Exception

images/aside-icons/info.png

Always include some kind of detail—either a specific custom error class or a snippet from the message—that is unique to the specific raise statement you are testing.

On the flip side, the negative form—expect { ... }.not_to raise_error(...)—has the opposite problem. If we give too much detail in our specs, we risk seeing a false positive. Consider this expectation, where an underlying age_of method is supposed to avoid a specific exception:

 expect​ { age__of(user) }.not_to raise_error(MissingDataError)

This snippet contains a hard-to-spot typo: we wrote age__of with two underscores. When Ruby executes this line, it will raise a NameError, which is notMissingDataError. The expectation will pass, even though our method never even runs!

Because this is such a thorny trap, RSpec 3 will warn you in this situation, and will suggest that you remove the exception class and just call not_to raise_error with no arguments. This form isn’t susceptible to the false positive problem, since it will catch any exception.

In fact, you can often simplify your specs even further. Since RSpec in effect wraps every example with expect { example.run }.not_to raise_error, you can remove your explicit not_to raise_error check—unless you want to keep it around for clarity.

throw_symbol

Exceptions are designed for, well, exceptional situations, such as an error in program logic. They’re not suited for everyday control flow, such as jumping out of a deeply nested loop or method call. For situations like these, Ruby provides the throw construct. You can test your control logic with RSpec’s throw_symbol matcher:

 expect​ { ​throw​ ​:found​ }.to throw_symbol(​:found​)

Ruby allows you to include an object along with the thrown symbol, and throw_symbol can likewise be used to specify that object via an additional argument:

 expect​ { ​throw​ ​:found​, 10 }.to throw_symbol(​:found​, a_value > 9)

Since throw_symbol is a higher-order matcher, the additional argument can be an exact value, an RSpec matcher, or any object that implements ===.

Yielding

Blocks are one of Ruby’s most distinctive features. They allow you to pass around little chunks of code using an easy-to-read syntax. Any method can pass control to its caller using the yield keyword, and RSpec provides four different matchers for specifying this behavior.

yield_control

The simplest yield matcher is yield_control:

 def​ self.just_yield
 yield
 end
 
 expect​ { |block_checker| just_yield(&block_checker) }.to yield_control

In order for the expectation to pass, the just_yield method must yield control to a block, or to an object that acts like a block. RSpec provides just such an object for us: a block checker that verifies that we actually yielded to it. All of the yield matchers use this technique.

You can also specify an expected number of yields by chaining once, twice, thrice, exactly(n).times, at_least(n).times or at_most(n).times.

 expect​ { |block| 2.times(&block) }.to yield_control.twice
 expect​ { |block| 2.times(&block) }.to yield_control.at_most(4).times
 expect​ { |block| 4.times(&block) }.to yield_control.at_least(3).times

The times method is just decoration, but it helps to keep your yield_control expectations readable.

yield_with_args

When you care about which specific arguments your method is yielding, you can check them with the yield_with_args matcher:

 def​ self.just_yield_these(*args)
 yield​(*args)
 end
 
 expect​ { |block|
  just_yield_these(10, ​'food'​, Math::PI, &block)
 }.to yield_with_args(10, ​/foo/​, a_value_within(0.1).of(3.14))

As this example shows, you can use several different criteria to check a yielded object, including:

  • An exact value such as the number 10
  • An object implementing ===, such as the regular expression /foo/
  • Any RSpec matcher, such as a_value_within()

yield_with_no_args

So far we have seen yield_control, which does not care about arguments, and yield_with_args, which requires certain arguments to be yielded. Sometimes, though, you specifically care that your code yields no arguments. For these cases, RSpec offers yield_with_no_args:

 expect​ { |block| just_yield_these(&block) }.to yield_with_no_args

yield_successive_args

Some methods, particularly those in the Enumerable module, can yield many times. To check this behavior, you’d need to combine yield_control’s counting ability with yield_with_args’s parameter checking.

That’s exactly what yield_successive_args does. To use it, you pass one or more arguments, each of which can be an object or a list of objects. The first object or list goes with the first call to yield, and so on:

 expect​ { |block|
  [​'football'​, ​'barstool'​].each_with_index(&block)
 }.to yield_successive_args(
  [​/foo/​, 0],
  [a_string_starting_with(​'bar'​), 1]
 )

The built-in Ruby function each_with_index will yield twice: first with the two values ’football’ and 0, then with the two values ’barstool’ and 1. As we did with yield_with_args, we’re checking these results using a mix of plain Ruby values, regular expression-style objects, and RSpec matchers.

Mutation

In the wild, it’s common for external actions—such as submitting a web form—to change some state inside the system. The change matcher helps you specify these sorts of mutations. Here’s the matcher in its most basic form:

 array = [1, 2, 3]
 expect​ { array << 4 }.to change { array.size }

The matcher performs the following actions in turn:

  1. Run your change block and store the result, array.size, as the before value
  2. Run the code under test, array << 4
  3. Run your change block a second time and store the result, array.size, as the after value
  4. Pass the expectation if the before and after values are different

This expectation checks whether or not the expectation changed, without regard to how much. For that, we’ll need to turn to another technique.

Specifying Change Details

Like other RSpec fluent matchers, the change matcher offers an easy way to give details about the change. Specifically, you can use by, by_at_least, or by_at_most to specify the amount of the change:

 expect​ { array.concat([1, 2, 3]) }.to change { array.size }.by(3)
 expect​ { array.concat([1, 2, 3]) }.to change { array.size }.by_at_least(2)
 expect​ { array.concat([1, 2, 3]) }.to change { array.size }.by_at_most(4)

If you care about the exact before and after values, you can chain from and to on to your matcher (either individually or together):

 expect​ { array << 4 }.to change { array.size }.from(3)
 expect​ { array << 5 }.to change { array.size }.to(5)
 expect​ { array << 6 }.to change { array.size }.from(5).to(6)
 expect​ { array << 7 }.to change { array.size }.to(7).from(6)

It probably doesn’t surprise you to hear that you can also pass a matcher (or any object that implements the === protocol) into from and to:

 x = 5
 expect​ { x += 10 }.to change { x }
  .from(a_value_between(2, 7))
  .to(a_value_between(12, 17))

Note that there’s a bit of a gotcha to passing a matcher, at least if you only use to or from (and not both). Consider this expectation:

 x = 5
 expect​ { x += 1 }.to change { x }.from(a_value_between(2, 7))

This expectation passes, because the value of x changed, and it was originally a value between 2 and 7. However, as this reads, you might expect that it would only pass if the final value of x was no longer between 2 and 7. If you care about both the before and after values, it’s a good idea to specify both from and to.

Negative Expectations

RSpec doesn’t allow you to use the three relative by... methods or the to method with the negative expectation form, expect { ... }.not_to change { ... }. After all, when you’re expecting a value not to change, it doesn’t make sense to spell out how much it didn’t change by or a value it didn’t change to.

Negative expectations do, however, work with from:

 x = 5
 expect​ { }.not_to change { x }.from(5)

In this example, we want x to stay at 5 before and after the block runs.

Output

Many Ruby tools write output to stdout or stderr, and RSpec includes a matcher specifically for these cases:

 expect​ { print ​'OK'​ }.to output(​'OK'​).to_stdout
 expect​ { warn ​'problem'​ }.to output(​/prob/​).to_stderr

This matcher works by temporarily replacing the global $stdout or $stderr variable with a StringIO while it runs your expect block. This generally works well but it does have some gotchas.

For example, if you use the STDOUT constant explicitly, or spawn a subprocess that writes to one of the streams, this matcher won’t work properly. Instead, you can chain to_std(out|err)_from_any_process for these situations:

 expect​ { system(​'echo OK'​) }.to output(​"OK​​ ​​"​).to_stdout_from_any_process

The ...from_any_process form uses a different mechanism: it temporarily reopens the stream to write to a Tempfile. This works in more situations, but is much, much slower—30x, according to our benchmarks. Thus, you need to explicitly opt in to this slower version of the output matcher.

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

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