Timecop: Freeze Time in Ruby Development for Better Testing

The API mentioned in this blog post is specific to v0.1.0. The latest version is v0.2.0. See the blog post announcement, the documentation, or the github home page.

This past weekend I released v0.1.0 of the Timecop gem. Timecop makes it dead simple to travel through or freeze time for the sake of creating a predictable and ultimately testable scenario.

The gem is derived from a plugin I wrote a while back to achieve more or less the same functionality for an extremely time-sensitive application. My goals for the gem included:

  1. Drop-in-ability : The primary goal is to allow your app to continue to use Time.now, Date.today and DateTime.now as normal within your application. No overloading of functions with optional arguments (a la today=Date.today) just so you can write test cases.
  2. Environment independence : I wanted the gem to work (a) w/ rails (ActiveSupport actually), (b) w/ plain ruby when the 'date' library has been loaded, and (c) w/ plain ruby when the 'date' library had not been loaded.
  3. Library independence : I could have utilized mocha to achieve the mocking functionality found under the hood, but because I wanted this to work with plain vanilla ruby, libraries like mocha are out.
  4. Short-term time travel : I wanted to expose the ability to temporarily change the concept of "now." This is particularly helpful when writing tests where time needs to pass.
  5. Long-range time travel : I wanted to expose the ability to change the concept of "now" for an indeterminate period of time. This is particularly helpful when setting up a rails test environment along with the test data.
  6. Nested time travel : I wanted to provide the ability to nest traveling, allowing the state to be kept within each block (we'll see an example later).

The gem is hosted on RubyForge and can be installed by simply running:

sudo gem install timecop  

Using Timecop

The most basic example is to utilize this within a test. Consider the following test case:

require 'timecop'  
require 'test/unit'

class MyTestCase < Test::Unit::TestCase  
  def test_mortgage_due_in_30_days
    john = User.find(1)
    john.sign_mortgage!
    assert !john.mortgage_payment_due?
    Timecop.travel(Time.now + 30.days) do
      assert john.mortgage_payment_due?
    end
  end
end  

What's nice about this API is that the #travel function will take several different object types. You can pass a Time instance, a Date instance, a DateTime instance, or individual time parameters (the same arguments Time.local()) takes. Also, if you're a big fan of the chronic gem, then you can easily combine these two:

Timecop.travel(Chronic.parse('this tuesday 5:00')) do  
  # test-fu
end  

For some applications, you may find it useful to completely re-base the concept of "now" for your test environment. Whenever time-sensitivity is a major priority for your application (e.g. an ordering and delivery system, or any system that encapsulates the concept of "availability" at a particularly fine-grained level) , you may find it useful to "root" your test data at a particular point in time, allowing you to write very specific tests that don't fall victim to the unstoppable march of real time.

To achieve a static date for your entire test environment, you can simply drop the following into config/environments/test.rb (or config/test/environment.rb if you use environmentalist):

# Setting "now" to May 15, 2008 10:00:00 AM
Timecop.travel(Time.local(2008, 5, 15, 10, 0, 0))  

This will cause your whole application to run at a static time. As mentioned previously, this is particularly important for creating static test cases. You may think that you can achieve this by always using relative times, but this proves much more difficult than initially perceived. For example, let's consider the following scenario, which is an extension of the mortgage example beforehand. Let's say the business rule is that a mortgage payment is due every month on the same date as the lease is signed, except for when that day falls on a weekend, in which case the payment would be due the following Monday. As such, it's extremely crucial for us to write a test case where we know the date in the next month falls on a weekend, and a separate test case where the date in the next month falls on a weekday. As such, there are certain months where the following test (not using Timecop) would fail:

require 'test/unit'

class MyTestCase < Test::Unit::TestCase  
  def test_mortgage_due_in_30_days
    john = User.find(1)
    john.sign_mortgage!
    assert !john.mortgage_payment_due?
    assert john.mortgage_payment_due?(Chronic.parse("1 month from now"))
  end
end  

Furthermore, you'll notice I had to pass a date to the mortgage_payment_due? function, essentially requiring me to change the signature of that function to take an optional date argument. Basically, in order to test my application, I've had to go ahead and change my actual code! Something clearly smells here. Another option would be to use mocha to mock Time.now. However, mocha has its own limitations with its inability to unmock. Lastly, specifically mocking Time.now would leave us high and dry if our implementation decided to start using DateTime.now for comparison in lieu of Time.now.

Another feature that I want to illustrate is the ability to nest #travel commands. The following assertions will all pass:

# this test is taken directly from the test/test_timecop.rb
  def test_recursive_travel
    t = Time.local(2008, 10, 10, 10, 10, 10)
    Timecop.travel(2008, 10, 10, 10, 10, 10) do
      assert_equal t, Time.now
      t2 = Time.local(2008, 9, 9, 9, 9, 9)
      Timecop.travel(2008, 9, 9, 9, 9, 9) do
        assert_equal t2, Time.now
      end
      assert_equal t, Time.now
    end
    assert_nil Time.send(:mock_time)
  end

Lastly, Timecop handles the scenario where exceptions are raised within the blocks passed to the #travel function.

# this test is taken directly from the test/test_timecop.rb
  def test_exception_thrown_in_travel_block_properly_resets_time
    t = Time.local(2008, 10, 10, 10, 10, 10)
    begin
      Timecop.travel(t) do
        assert_equal t, Time.now
        raise "blah exception"
      end
    rescue
      assert_not_equal t, Time.now
      assert_nil Time.send(:mock_time)
    end
  end

Timecop is useful both in small and large consumption. Just as mocha can be helpful to give you a quick mock here and there, Timecop can help guide you very easily through testing the few time-sensitive parts of your application. And for those unique applications where everything is time-sensitive, Timecop gives you a very simple way to stabilize your concept of now.

 

Want to learn more about Ruby on Rails and our Rails Development, get in touch.

comments powered by Disqus