Have you ever had a test that passed at some times of the day, but not others? What about a test that passed on certain days of the week? How do you run tests to make sure your application works right on leap years? Testing code involving dates can be trickly. Luckily, it’s easy to freeze time, at least in ruby.
After spending some time pulling out my hair trying to test billing statement generation code, I came to the realization that the only way to get repeatable test results was to freeze time. Lacking that kind of superpower, I used ruby’s metaprogramming to give myself the ability to freeze time:
class Test::Unit::TestCase def freeze_time(frozen_time=946702800) Time.instance_eval do frozen_now=(frozen_time) alias :original_now :now alias :now :frozen_now end if block_given? begin yield ensure unfreeze_time end end end def unfreeze_time Time.instance_eval do alias :now :original_now end end end class Time def self.frozen_now=(val) @frozen_now=val end def self.frozen_now Time.at(@frozen_now || 946702800) end end
I include that code in my test_helper in all of my applications. That gives me the ability to write a test that looks like:
def test_new_comment freeze_time do @user=users(:mike) @expected.subject="A comment was made on your profile" @expected.body=read_fixture('new_comment') @expected.from="ER <email@example.com>" @expected.to="firstname.lastname@example.org" @expected.date = Time.now assert_equal @expected.encoded, UserMailer.create_new_comment(@user).encoded end end
Everything that is executed inside the freeze_time block executes with
Time.now returning a set value. Don’t worry about
@expected.date being different than the time on the new message.
That was really easy! So how does it work? Let’s start at the bottom:
class Time def self.frozen_now=(val) @frozen_now=val end def self.frozen_now Time.at(@frozen_now || 946702800) end end
Here, we’re adding methods called
Time. After we do this, we can call
Time.frozen_now and get either a default time (01/01/2000 00:00:00) or the time that we set in
Time.frozen_now=. That’s nice, but doesn’t solve our problem.
Time.now still returns the current time. The first snippet takes care of that.
def freeze_time(frozen_time=946702800) Time.instance_eval do frozen_now=(frozen_time) alias :original_now :now alias :now :frozen_now end if block_given? begin yield ensure unfreeze_time end end end
Here, we’re executing some code inside the
Time class. That code calls
frozen_now= to set the time returned by
frozen_now(). Next, it makes an alias of the
now() method and calls it
original_now. (We’ll need this later). After that, it creates and alias of
frozen_now() and names it
now. The end result is that
Time.now() calls our
frozen_now() method, and
Time.original_now() calls the original version. The block given section is a handy helper. It let’s you use
frozen_now in a block and will automatically undo the aliasing by calling
unfreeze_time. If you don’t pass in a block, you are responsible to call
unfreeze_time yourself, or you will be stuck in the past forever!
Notice the use of ensure inside the block. We want to make sure that even if an exception is raised inside the yielded code, we still undo our changes. That’s a really important step in this type of programming. One final note on this is that you don’t want to use
unfreeze_time in the
teardown of a test. If you do that, and your test loads fixtures for the first time, those fixtures will be loaded with the frozen time. That can cause some hard to debug problems later!
So while we don’t have superpowers, you now have the ability to freeze time at any point in the past or the future for the duration of a test.
While this code will still work, another option is to use the handy timecop gem.