SmartLogic Logo (443) 451-3001

The SmartLogic Blog

SmartLogic is a web and mobile product development studio based in Baltimore. Contact us for help building your product or visit our website to learn more about what we do.

Timecop: Freeze Time in Ruby for Better Testing

November 19th, 2008 by
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.

  • http://blog.teksol.info/ François Beausoleil

    Thank you very much for that. I had started such a library, but I stopped after I coded a couple of test cases. I’ll enjoy using a good solution, instead of my half-baked one :)

  • http://zealog.com Aaron H.

    This is AWESOME. I work on several apps that have time as a key component and this is going to be SO useful. THANK YOU!

  • anonymouse

    So, does time still move forward after a Time.travel? That would be sweet, since some things may use time internally for benchmarking/timeouts/etc.

  • http://www.smartlogicsolutions.com/wiki/John_Trupiano John Trupiano

    Francois, Aaron, thanks for the kudos. I hope you’re finding the gem useful. It’s working very well for us on several projects.

    anonymouse: time will not continue to move forward, and as you point out, this does reduce the usable scope for the gem. I would LOVE to put this feature into Timecop, but it’s not something I’ve looked into.

    I imagine a usable solution would involve the following:

    1) Take the new time/date from the #travel() function and store it.
    2) Compute deltas (offsets) for Time.now, DateTime.now and Date.today
    3) Mock Time.now, DateTime.now and Date.today to return the current time + those deltas (rather than just returning the original mock)

    It sounds do-able to me, perhaps a project for a free weekend! If you feel so inclined, the project is available on github (http://github.com/jtrupiano/timecop). Otherwise, it’s certainly something to put on my to-do list.

    Thanks for checking in.

  • https://www.zipitax.com Scott Moe

    Awesome! We’re building a site for filing sales tax and this is exactly what we need to build useful tests that will work now and a year from now.

  • http://www.smartlogicsolutions.com/wiki/John_Trupiano John Trupiano

    Hey Scott, glad Timecop is working out for you!

    To all commenters (in particular anonymouse), take note that I’ve release version 0.2.0, which most importantly provides the ability to either “freeze” or “rebase” time (as opposed to only 0.1.0, which only froze time). See the blog post (http://blog.smartlogicsolutions.com/2008/12/24/timecop-2-released-freeze-and-rebase-time-ruby/) or check out the project on github: http://github.com/jtrupiano/timecop

  • http://wagn.org Lewis Hoffman

    Adding a notification cron job to Wagn (wagn.org) & this will be perfect for building the tests, thank you!

  • Hammed

    One of the most useful testing plugins for rails! cheers

  • http://woss.name/ Graeme Mathieson

    Part of our system performs daily recalculations for each day between an event happening and today. It also has to be able to account for changes in tax law over the past 4 years. This combination has meant our test suite gets slower every day, since we have tests verifying correct behaviour immediately before and after tax law changes.

    Refactoring it to avoid the date sensitivity is a big job, and one that we’ve been putting off for a while (though we will still get to it one day!). Incorporating Timecop took 10 minutes of dev work (most of which was identifying the hotspots) and has shaved 90 seconds (120s down to 30s) from one set of tests. Quick win! Thank you!

  • http://www.smartlogicsolutions.com/wiki/John_Trupiano John Trupiano

    Wow, that’s awesome Graeme!

John Trupiano co-founded SmartLogic with Yair Flicker in May 2005 and was co-president through 2011. Check out his GitHub Projects or follow @jtrupiano on Twitter.

John Trupiano's posts