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 fromMinitest::Test
.travel
andtravel_to
can be used to changeTime.now
in tests.travel_back
must be called unless you usetravel_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
- Reading Rails - HTTP DELETEs With a Link
- Reading Rails - Time Travel
- Reading Rails - TimeWithZone
- Reading Rails - How Does MessageEncryptor Work?
- Reading Rails - How Does MessageVerifier Work?
- Reading Rails - How Do Batched Queries Work?
- Reading Rails - The Adapter Pattern
- Reading Rails - Errors and Validators
- Reading Rails - How Validations Are Configured
- Reading Rails - Concern
- Reading Rails - More Migrations
- Reading Rails - Migrations
- Reading Rails - Attribute Methods
- Reading Rails - Change Tracking
- Reading Rails - Handling Exceptions