In Progress
Unit 1, Lesson 1
In Progress

Testing Blocks

Video transcript & code

Back in episode 38 we looked at a method to fetch the temperature for a given region code. For maximum flexibility, we made it possible for the caller to specify what to do in case of a failure by passing the failure policy as a block. For example, we could use the block to simply return a string that says "not found". We could have the block raise an exception. Or we could use the message from the error to output a warning.

require 'ostruct'
require 'open-uri'
require 'json'

class TemperatureApiError < StandardError
end

DEFAULT_FALLBACK = ->(error) { raise }

def get_temp(query, &fallback)
  fallback ||= DEFAULT_FALLBACK
  key  = ENV['WUNDERGROUND_KEY']
  url  = "http://api.wunderground.com/api/#{key}/conditions/q/#{query}.json"
  data = open(url).read
  JSON.parse(data)['current_observation']['temp_f']
rescue => error
  fallback.call(error)
end
require './get_temp.rb'
get_temp("00000") { "Region not found" }
# => "Region not found"

result = get_temp("00000") do
  raise ArgumentError, "Region not found"
end rescue $!
result # => #<ArgumentError: Region not found>

require 'logger'
logger = Logger.new($stdout)
get_temp("00000") do |error|
  logger.error error.message
end
# >> E, [2013-07-01T15:26:02.697783 #2911] ERROR -- : undefined method `[]' for nil:NilClass

One question I've heard a few times about like methods is how to test them. Here's the way I typically do it these days. I set up a variable to indicate whether the expected yield occurred. Then I call the method under test with a block, and inside the block I update that variable. Then I simply verify that the this "probe" variable has been updated.

require 'rspec/autorun'
require './get_temp'

describe '#get_temp' do
  it 'yields to a block on error' do
    yielded = false
    get_temp("00000") do
      yielded = true
    end
    expect(yielded).to be_true
  end
end
# >> .
# >> 
# >> Finished in 0.39212 seconds
# >> 1 example, 0 failures

It's kind of primitive, but it gets the job done.

Sometimes we may want to verify not just that the block is executed, but that specific arguments are yielded into it as well. In this case, I use the probe variable to capture the yielded value. Then I check the value when the call has completed.

require 'rspec/autorun'
require './get_temp'

describe '#get_temp' do
  it 'yields the exception to a block on error' do
    yielded_value = nil
    get_temp("00000") do |error|
      yielded_value = error
    end
    expect(yielded_value).to be_a(Exception)
  end
end
# >> .
# >> 
# >> Finished in 0.39137 seconds
# >> 1 example, 0 failures

There is a subtle potential problem in this test code though. To illustrate it, I'm going to introduce a bug by commenting out the part of the method under test that yields to the block.

require 'ostruct'
require 'open-uri'
require 'json'

DEFAULT_FALLBACK = ->(error) { raise }

def get_temp(query, &fallback)
  fallback ||= DEFAULT_FALLBACK
  key  = ENV['WUNDERGROUND_KEY']
  url  = "http://api.wunderground.com/api/#{key}/conditions/q/#{query}.json"
  data = open(url).read
  JSON.parse(data)['current_observation']['temp_f']
rescue => error # !> assigned but unused variable - error
  # fallback.call(error)
end

require 'rspec/autorun'

describe '#get_temp' do
  it 'yields the exception to a block on error' do
    yielded_value = nil
    get_temp("00000") do |error|
      yielded_value = error
    end
    expect(yielded_value).to be_a(Exception)
  end
end
# >> F
# >> 
# >> Failures:
# >> 
# >>   1) #get_temp yields the exception to a block on error
# >>      Failure/Error: Unable to find matching line from backtrace
# >>        expected nil to be a kind of Exception
# >>      # -:28:in `block (2 levels) in <main>'
# >> 
# >> Finished in 0.3453 seconds
# >> 1 example, 1 failure
# >> 
# >> Failed examples:
# >> 
# >> rspec -:23 # #get_temp yields the exception to a block on error

OK, we see the failure is that we expected an Exception but got nil instead. Now I'm going to introduce a different bug by re-enabling the block execution but leaving off the block argument.

require 'ostruct'
require 'open-uri'
require 'json'

DEFAULT_FALLBACK = ->(error) { raise }

def get_temp(query, &fallback)
  fallback ||= DEFAULT_FALLBACK
  key  = ENV['WUNDERGROUND_KEY']
  url  = "http://api.wunderground.com/api/#{key}/conditions/q/#{query}.json"
  data = open(url).read
  JSON.parse(data)['current_observation']['temp_f']
rescue => error # !> assigned but unused variable - error
  fallback.call
end

require 'rspec/autorun'

describe '#get_temp' do
  it 'yields the exception to a block on error' do
    yielded_value = nil
    get_temp("00000") do |error|
      yielded_value = error
    end
    expect(yielded_value).to be_a(Exception)
  end
end
# >> rspec -:23 # #get_temp yields the exception to a block on error  
# >> F
# >> 
# >> Failures:
# >> 
# >>   1) #get_temp yields the exception to a block on error
# >>      Failure/Error: Unable to find matching line from backtrace
# >>        expected nil to be a kind of Exception
# >>      # -:25:in `block (2 levels) in <main>'
# >> 
# >> Finished in 0.32629 seconds
# >> 1 example, 1 failure
# >> 
# >> Failed examples:
# >> 
# >> rspec -:20 # #get_temp yields the exception to a block on error

See the difference? That's a trick question, because there is no difference! It's the exact same failure message. By making the starting value of our probe variable nil, we've made it impossible to differentiate between the case where the block is never executed, and the case where it is executed but yields a nil instead of an exception.

We can fix this by making the starting value of the probe variable an arbitrary value which is highly unlikely to be yielded by the code under test. I like to use symbols with meaningful names like :has_not_yielded. Now when we disable the yield, we get a more useful failure message: expected an Exception, but got the symbol :has_not_yielded.

require 'ostruct'
require 'open-uri'
require 'json'

DEFAULT_FALLBACK = ->(error) { raise }

def get_temp(query, &fallback)
  fallback ||= DEFAULT_FALLBACK
  key  = ENV['WUNDERGROUND_KEY']
  url  = "http://api.wunderground.com/api/#{key}/conditions/q/#{query}.json"
  data = open(url).read
  JSON.parse(data)['current_observation']['temp_f']
rescue => error # !> assigned but unused variable - error
  # fallback.call
end

require 'rspec/autorun'

describe '#get_temp' do
  it 'yields the exception to a block on error' do
    yielded_value = :has_not_yielded
    get_temp("00000") do |error|
      yielded_value = error
    end
    expect(yielded_value).to be_a(Exception)
  end
end
# >> rspec -:20 # #get_temp yields the exception to a block on error  
# >> F
# >> 
# >> Failures:
# >> 
# >>   1) #get_temp yields the exception to a block on error
# >>      Failure/Error: Unable to find matching line from backtrace
# >>        expected :has_not_yielded to be a kind of Exception
# >>      # -:25:in `block (2 levels) in <main>'
# >> 
# >> Finished in 0.29351 seconds
# >> 1 example, 1 failure
# >> 
# >> Failed examples:
# >> 
# >> rspec -:20 # #get_temp yields the exception to a block on error

One last thing. How about testing methods which may yield multiple times? In this case, I simply set the probe to an empty array, and collect values as they are yielded. Then I test for the values I'm looking for in the updated probe array.

require 'rspec/autorun'
describe 'Hash#each' do
  it 'yields keys and values' do
    h = {x: 23, y:42}
    yielded_values = []
    h.each do |key, value|
      yielded_values << [key, value]
    end
    expect(yielded_values).to include([:x, 23])
    expect(yielded_values).to include([:y, 42])
  end
end
# >> .
# >> 
# >> Finished in 0.01528 seconds
# >> 1 example, 0 failures

As far as testing methods that yield to blocks goes, that's pretty much all there is to it. If there is a case that I haven't covered here, feel free to send it my way. Until next timeā€¦ happy hacking!

Responses