Reading Rails - Time Travel

This article references [Rails 4.2.0](https://github.com/rails/rails/tree/7847a19f476fb9bee287681586d872ea43785e53). Some code snippets have been abbreviated for clarity.

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?

Follow along:

`qw activesupport 4.2`

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

`:nodoc:` means "extra fun time".

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)

[Read up on `Struct`](http://ruby-doc.org/core-2.2.1/Struct.html) it's worth your time.

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 }

James Coglan covers single classes in [How Ruby method dispatch works](https://blog.jcoglan.com/2013/05/08/how-ruby-method-dispatch-works/).

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 .

inherits from . travel and travel_to can be used to change Time.now in tests.

and can be used to change in tests. travel_back must be called unless you use travel_to with a block.

must be called unless you use with a block. ensure blocks will always be called, even if an exception is raised.

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 .

Please enable JavaScript to view the comments powered by Disqus.

Disqus