In Progress
Unit 1, Lesson 21
In Progress

Testing Blocks With Rspec

Video transcript & code

In the last episode we looked at a simple technique for testing that a method yields to a block. The example I showed used RSpec, but there was nothing about the testing strategy that was unique to RSpec. It could have been used in any testing framework.

However, if we are using RSpec in a project, we can take advantage of some lesser-known RSpec tools for specifying that a method should or should not yield to a block. Since quite a few projects out there use RSpec for testing, I thought I'd cover those techniques today.

Let's say we're specifying a method that should seek through a string looking for email addresses. When it finds one, it should yield it.

For the assertion, we start with expect, but instead of passing it arguments the way we usually do, we open a block. The expect block receives a special probe argument, provided by RSpec.

Inside the block we execute the method under test with the input we set up earlier. We also pass it the probe, using & to tell Ruby to use that object in place of an explicit block. RSpec will use this probe to determine if the method under test yields or not.

Finally, at the end of the expect block we add our expectation: that the method will yield control to the probe.

Making this test pass is as simple as defining the method and having it yield control.

require 'rspec/autorun'

def find_email_addresses(input)
  yield
end

describe '#find_email_addresses' do
  it 'yields when an email address is found in input' do
    input = "contact@shiprise.net"
    expect{|probe| find_email_addresses(input, &probe)}.to yield_control
  end
end
# >> .
# >> 
# >> Finished in 0.0012 seconds
# >> 1 example, 0 failures

Clearly this method doesn't do all that its name suggests yet. The next test we write specifies that when given input that contains no email addresses, it will not yield control. We specify by passing input that contains no email address, and using not_to in place of to in the expectation.

The failure we get shows off one of the benefits of using RSpec and these special matchers. It says "expected given block not to yield control". Failure messages don't get much clearer than that!

require 'rspec/autorun'

def find_email_addresses(input)
  yield
end

describe '#find_email_addresses' do
  it 'yields when an email address is found in input' do
    input = "contact@shiprise.net"
    expect{|probe| find_email_addresses(input, &probe)}.to yield_control
  end

  it 'does not yield when no emails are found' do
    input = "not an email address"
    expect{|probe| find_email_addresses(input, &probe)}.not_to yield_control
  end
end
# >> .F
# >> 
# >> Failures:
# >> 
# >>   1) #find_email_addresses does not yield when no emails are found
# >>      Failure/Error: Unable to find matching line from backtrace
# >>        expected given block not to yield control
# >>      # -:15:in `block (2 levels) in <main>'
# >> 
# >> Finished in 0.00149 seconds
# >> 2 examples, 1 failure
# >> 
# >> Failed examples:
# >> 
# >> rspec -:13 # #find_email_addresses does not yield when no emails are found

We make the test pass by updating the method to actually look for an email address.

require 'rspec/autorun'

def find_email_addresses(input)
  if input =~ /[A-Z0-9+_.-]+@[A-Z0-9.-]+/i
    yield
  end
end

Now the point of this method is to actually yield the matched email addresses. So we need to add an assertion that not only does it yield control when it finds a match, it also yields the matching string to the block as an argument. We can do this using the yield_with_args matcher.

require 'rspec/autorun'

def find_email_addresses(input)
  if input =~ /[A-Z0-9+_.-]+@[A-Z0-9.-]+/i
    yield
  end
end

describe '#find_email_addresses' do
  it 'yields when an email address is found in input' do
    input = "contact@shiprise.net"
    expect{|probe| find_email_addresses(input, &probe)}.to yield_control
  end

  it 'does not yield when no emails are found' do
    input = "not an email address"
    expect{|probe| find_email_addresses(input, &probe)}.not_to yield_control
  end

  it 'yields the found email address' do
    input = "blah blah bob@example.com yadda yadda"
    expect{|probe| find_email_addresses(input, &probe)}.to yield_with_args("bob@example.com")
  end
end
# >> ..F
# >> 
# >> Failures:
# >> 
# >>   1) #find_email_addresses yields the found email address
# >>      Failure/Error: Unable to find matching line from backtrace
# >>        expected given block to yield with arguments, but yielded with unexpected arguments
# >>        expected: ["bob@example.com"]
# >>             got: [] (compared using === and ==)
# >>      # -:22:in `block (2 levels) in <main>'
# >> 
# >> Finished in 0.00159 seconds
# >> 3 examples, 1 failure
# >> 
# >> Failed examples:
# >> 
# >> rspec -:20 # #find_email_addresses yields the found email address

Again we get a very readable failure message telling us exactly what the method did wrong. We make the test pass by switching to the match method and passing the matched string to yield.

require 'rspec/autorun'

def find_email_addresses(input)
  if match = input.match(/[A-Z0-9+_.-]+@[A-Z0-9.-]+/i)
    yield(match[0])
  end
end

describe '#find_email_addresses' do
  it 'yields when an email address is found in input' do
    input = "contact@shiprise.net"
    expect{|probe| find_email_addresses(input, &probe)}.to yield_control
  end

  it 'does not yield when no emails are found' do
    input = "not an email address"
    expect{|probe| find_email_addresses(input, &probe)}.not_to yield_control
  end

  it 'yields the found email address' do
    input = "blah blah bob@example.com yadda yadda"
    expect{|probe| find_email_addresses(input, &probe)}.to yield_with_args("bob@example.com")
  end
end
# >> ...
# >> 
# >> Finished in 0.00147 seconds
# >> 3 examples, 0 failures

OK, but what about when the input contains multiple email addresses? Let's add another test for that case. We'll pass some input that contains multiple email addresses, with some extra text in between them. Then we'll assert that each email address is yielded in order using the yield_successive_arguments matcher.

require 'rspec/autorun'

def find_email_addresses(input)
  if match = input.match(/[A-Z0-9+_.-]+@[A-Z0-9.-]+/i)
    yield(match[0])
  end
end

describe '#find_email_addresses' do
  it 'yields when an email address is found in input' do
    input = "contact@shiprise.net"
    expect{|probe| find_email_addresses(input, &probe)}.to yield_control
  end

  it 'does not yield when no emails are found' do
    input = "not an email address"
    expect{|probe| find_email_addresses(input, &probe)}.not_to yield_control
  end

  it 'yields the found email address' do
    input = "blah blah bob@example.com yadda yadda"
    expect{|probe| find_email_addresses(input, &probe)}.to yield_with_args("bob@example.com")
  end

  it 'yields multiple found email addresses' do
    input = "contact@shiprise.net blah blah bob@exampl.com"
    expect{|probe| find_email_addresses(input, &probe)}.to yield_successive_args(
      "contact@shiprise.net", "bob@example.com")
  end
end
# >> ...F
# >> 
# >> Failures:
# >> 
# >>   1) #find_email_addresses yields multiple found email addresses
# >>      Failure/Error: Unable to find matching line from backtrace
# >>        expected given block to yield successively with arguments, but yielded with unexpected arguments
# >>        expected: ["contact@shiprise.net", "bob@example.com"]
# >>             got: ["contact@shiprise.net"] (compared using === and ==)
# >>      # -:27:in `block (2 levels) in <main>'
# >> 
# >> Finished in 0.00171 seconds
# >> 4 examples, 1 failure
# >> 
# >> Failed examples:
# >> 
# >> rspec -:25 # #find_email_addresses yields multiple found email addresses

The failure message shows the expected sequence of yielded values, versus the actual sequence. We make the test pass by switching from doing a single match to using the String#scan method to iterate over all matches of a regular expression.

require 'rspec/autorun'

def find_email_addresses(input)
  input.scan(/[A-Z0-9+_.-]+@[A-Z0-9.-]+/i) do |match|
    yield(match)
  end
end

We've now encountered most of RSpec's yield matchers. As you can see, these helpers give us some very intention-revealing language for specifying the behavior of methods which may yield control back to their callers. On the other hand, they feel a little bit like "magic" when compared to simply using a probe variable to verify a block was executed in the expected way. As with many of RSpec's specialized syntaxes, it's ultimately a matter of taste whether you prefer to use the matchers or use more manual methods for testing blocks.

I'd like to thank Myron Marston for pointing me to these yield matchers. Happy hacking!

Responses