In Progress
Unit 1, Lesson 21
In Progress

Testing Sleep

Video transcript & code

The other day I was working on some code for the RubyTapas site, and I wrote this method.

def create_thumbnail(url, attributes={})
  new_url = custom_thumbnail_url(url, attributes)
  loop do
    response = Net::HTTP.get_response(URI.parse(new_url))
    case code = response.code.to_i
    when 202 then sleep 1
    when 200 then break
    else
      raise "Unrecognized response code #{code}"
    end
  end
  new_url
end

It's called #create_thumbnail. This method's job is to make a special request to Wistia, the website that hosts the RubyTapas free sample videos. The Wistia site can provide video preview thumbnails with any desired pixel width and height, as specified in the request query string. There's just one complication: the new thumbnail image might not become available immediately. Instead, the site will return an HTTP 202 response indicating that the request has been accepted and the site is working on it. Once the new thumbnail is ready, the same URL returns an HTTP 200 status, along with the image data.

This method is intended to create a new thumbnail and then wait until it is actually ready to be served by the Wistia site before returning. In order to do this, it contains a loop. The loop makes an HTTP request, and then branches on the response code. When it gets a 202, it waits for a second, using the #sleep method. Once it receives a 200, it returns.

I say that I wrote this method, but that's not quite true. See, I was using test-driven development as I wrote this code, and unit testing a method like this is a real headache.

First there's the problem of simulating HTTP requests. I was using the FakeWeb gem for that part. But then there's the matter of the #sleep.

If I simply left the sleep alone, and used FakeWeb to queue up some simulated 202 responses, a single test for this method would take multiple seconds to complete. That's completely unacceptable, as far as I'm concerned. Unit tests must be fast to be useful. If they are slow, I'll start running them and writing them less and less as my frustration level grows.

I could stub out the #sleep method on the object under test. But that breaks one of my fundamental rules for unit tests, which is that the object being tested should be treated like a black box. It shouldn't be specially modified to suit the test. These kinds of testing monkey-patches bind the test tightly to the implementation, making the tests fragile and more about implementation than intent. In the worst case, they become tautologies: tests that pass only because the code is rewritten to force them to pass.

gateway.stub(:sleep)

I could also have hatched a scheme for passing in a "waiter" object whose sole role is to wait, but that seems like overcomplicating the design just for the sake of the test.

gateway.create_thumbnail(url, attributes, FakeWaiter.new)

I thought about what it was I really wanted to test. I realized I wasn't interested in testing that the method called #sleep correctly. Maybe if the method was dynamically calculating the amount of time to sleep I might have cared about that. But it's hard to mess up a simple 1-second sleep.

The only thing interesting about this method—the only tricky part I wanted to ensure worked at the time I wrote it, and continued to work correctly into the future—was that the method would continue to wait and retry so long as it got 202 codes, and finished once it received a 200. That's the core logic which makes this code worthy of being its own method at all.

With this in mind, I decided to make it possible to easily override how the method handled the 202 case. I gave it a block argument, called on_not_ready. When not explicitly provided, I had that argument default to a lambda that simply called sleep. And I wrote the 202 case to invoke that block argument.

def create_thumbnail(url, attributes={}, &on_not_ready)
  on_not_ready ||= -> { sleep 1 }
  new_url = custom_thumbnail_url(url, attributes)
  loop do
    response = Net::HTTP.get_response(URI.parse(new_url))
    case code = response.code.to_i
    when 202 then on_not_ready.call
    when 200 then break
    else
      raise "Unrecognized response code #{code}"
    end
  end
  new_url
end

This enabled me to write a test which replaces the 1-second wait with a block of code that simply increments the number of times the method has waited and retried.

Of course, this means that the code behaves slightly differently under test than it does in production. That lambda with the sleep in it will never be exercised by this test.

But sometimes that's unavoidable when testing code that performs I/O or otherwise interacts with the system. The important thing is to separate testing the code's logic from testing how the code interacts with the rest of the system. Sometimes, the part that deals with the outside world is so trivial it isn't worth testing, as is the case with this method. When external interactions are more complex, we need to turn to other strategies, such as dedicated adapter classes whose only job is dealing with external services.

One last thing. As is often the case when writing code to be easily testable, as a nice side effect this code is now very extensible. As a simple example, callers can easily change how long it waits before retrying by passing a block with a different sleep.

gateway.create_thumbnail(url, attributes) do
  sleep 0.5
end

Here's a more advanced example. This code uses a progress bar to inform the user when it is waiting to retry.

progress = ProgressBar.create(total: nil)
gateway  = WistiaGateway.new
thumbnails.each do |new_thumbnail_url|
  gateway.create_thumbnail(new_thumbnail_url, attributes) do
    progress.log "Waiting for thumbnail to be created"
    progress.increment
    sleep 1
  end
end

Once again we see that passing behavior around can be more powerful than passing data. And our quest for fast, meaningful tests can help guide us in that direction. Happy hacking!

Responses