Tests involving dates and freezing time

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 <no-reply@elevatedrails.com>"
      @expected.to="aaron@example.com"
      @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 frozen_now= and frozen_now to 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 freeze_time and unfreeze_time in the setup and 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.

Update

While this code will still work, another option is to use the handy timecop gem.