Reading Rails - Time Travel


Rails comes with its very own time machine. In Rails 4.1 the travel and travel_to helpers were introduced. These provide a simple interface for testing time related code. For instance:

class CookieTest < ActiveSupport::TestCase
  test "cookies should be stale after 1 week" do
    cookie = Cookie.new
  
    travel 1.week do
      assert cookie.stale?
    end
  end
end

Let’s see how Rails implements this bit of magic by reading some source code!

Where Do TimeHelpers Come From?

Our first order of business is to track down where these helpers are defined. If you look in ActiveSupport’s testing/time_helpers.rb, and scroll down, you’ll find the TimeHelpers module. This contains the helper methods, now let’s find out how we’re able to call them in tests.

Since TimeHelpers is a Module, something must include it. Searching ActiveSupport for TimeHelpers leads us to test_case.rb, and ActiveSupport::TestCase.

module ActiveSupport
  class TestCase < ::Minitest::Test
    #...

The default tests Rails generates inherit from Minitest::Test, so if you ever want to know more about how your tests work, you can read up on Minitest.

Scrolling down you’ll see all the different modules ActiveSupport::TestCase includes:

include ActiveSupport::Testing::TaggedLogging
include ActiveSupport::Testing::SetupAndTeardown
include ActiveSupport::Testing::Assertions
include ActiveSupport::Testing::Deprecation
include ActiveSupport::Testing::TimeHelpers
extend ActiveSupport::Testing::Declarative

Sure enough, there’s ActiveSupport::Testing::TimeHelpers. If you’re ever curious about what Rails adds to Minitest, this is a great place to start looking.

Building a Ruby Time Machine

Jump back to testing/time_helpers.rb, and let’s look at how travel is implemented:

def travel(duration, &block)
  travel_to Time.now + duration, &block
end

This just calls travel_to after adding duration to the current time. The ampersand on &block in the arguments means that if a block is passed in, it will be bound to the block variable. By putting the ampersand on &block when calling travel_to, Ruby will pass it as a block, not another argument.

Here’s a quick illustration of how the & affects calls in Ruby:

def example(arg=nil, &block)
  puts "Got arg" if arg
  puts "Got block" if block
end

example{|x| x + 1}            # Got block
example(Proc.new{|x| x + 1})  # Got arg
example(&Proc.new{|x| x + 1}) # Got block

Let’s pick apart travel_to now.

def travel_to(date_or_time)
  if date_or_time.is_a?(Date) && !date_or_time.is_a?(DateTime)
    now = date_or_time.midnight.to_time
  else
    now = date_or_time.to_time.change(usec: 0)
  end
  #...

This method first determines what time we should should travel to. If it’s a Date, and not a DateTime, ActiveSupport will assuming midnight of that day, which is probably reasonable. Otherwise it will be converted to a plain Time. I was curious about the change(usec: 0). This zeros out the microseconds, but why? Apparently according to the commit message, that is done to:

Fix rounding errors with #travel_to by resetting the usec on any passed time to zero, so we only travel with per-second precision, not anything deeper than that. —DHH

When in doubt, git annotate is always a great tool when you want to understand why code was written a certain way.

The next bit is where travel_to performs its magic:

simple_stubs.stub_object(Time, :now, now)
simple_stubs.stub_object(Date, :today, now.to_date)

If you haven’t used libraries like mocha, you may not be familiar with stubs. Stubbing an object means you’re going to bypass its normal functionality for testing purposes. simple_stubs.stub_object is redefining Time and Date to always return the value now that we got above. We’ll dig into SimpleStubs after we finish this method.

travel_to finishes with some cleanup code:

if block_given?
  begin
    yield
  ensure
    travel_back
  end
end

If a block is passed in this will call then block, and then call travel_back, which undoes the stubbing above. The code in ensure will always get called, even if an exception gets raised. If you ever need to be certain that some cleanup code is called, consider using begin and ensure.

What happens if a block isn’t passed in? In that case, Time and Date will remain stubbed until you call travel_back. If you’re not careful, you may end up affecting other tests. To avoid this issue, consider calling travel_back after each test.

def teardown
  # Reset Time/Date
  travel_back
end

Now what does simple_stubs do?

def simple_stubs
  @simple_stubs ||= SimpleStubs.new
end

Looks like were’ going to have to dig a little deeper.

Rails’ Secret Stubbing Library

Rails doesn’t advertise SimpleStubs, so you probably shouldn’t be calling it directly. In fact it’s explicitly not documented, but that doesn’t mean we shouldn’t figure out how it works.

So what’s in this little class? It starts off by declaring a simple class using Struct:

module ActiveSupport
  module Testing
    class SimpleStubs # :nodoc:
      Stub = Struct.new(:object, :method_name, :original_method)

Using Struct is a great alternative to using hashes. It gives you a simple class with accessors for symbol passed in.

SimpleStubs#initialize creates a Hash to track the stubbed methods:

def initialize
  @stubs = {}
end

Now for the real trick, temporarily redefining a method on a single object:

def stub_object(object, method_name, return_value)
  key = [object.object_id, method_name]

  if stub = @stubs[key]
    unstub_object(stub)
  end

  new_name = "__simple_stub__#{method_name}"

  @stubs[key] = Stub.new(object, method_name, new_name)

  object.singleton_class.send :alias_method, new_name, method_name
  object.define_singleton_method(method_name) { return_value }
end

Let’s walk through what happens when travel_to calls this method with simple_stubs.stub_object(Time, :now, now).

First a key is generated so the stub can be looked up later. stub_object only changes a single object, so key is composed of that object’s unique object_id and the name of the method we’re stubbing. So for Time.now this might look like:

key = [Time.object_id, :now] #=> [19463520, :now]

Next, stub_object checks to see if the method is already stubbed, if so, it undoes the previous stubbing.

A temporary name is used to keep a reference to the old method so Rails can swap it back into place in unstub_object.

new_name = "__simple_stub__#{method_name}" #=> "__simple_stub__now"

Finally, the original method is aliased to a new name, and a new method is defined in it’s place:

object.singleton_class.send :alias_method, new_name, method_name
object.define_singleton_method(method_name) { return_value }

For our example, that means that Time.now will now be called Time.__simple_stub__now, and a new Time.now method will be defined that just returns now that was passed in. For our purposes, singleton_class returns a class that can be modified for just this object, and define_singleton_method defines a method only on this object.

Recap

We learned how Rails’ TimeHelpers work. Along the way, we also saw a simple example of how methods can be stubbed out in tests. Here are a few other tidbits to take away from this code:

  • ActiveSupport::TestCase inherits from Minitest::Test.
  • travel and travel_to can be used to change Time.now in tests.
  • travel_back must be called unless you use travel_to with a block.
  • ensure blocks will always be called, even if an exception is raised.
  • Objects can be passed as blocks using an ampersand (&arg)
  • ActiveSupport contains a tiny stubbing library
  • define_singleton_method defines a method for a single object

If you want to go further, you might consider reading some of the other modules that ActiveSupport includes like ActiveSupport::Testing::Declarative.

More articles in this series