Wednesday, February 25, 2009

Time Keeps On Slipping, So Freeze It

A friend of a friend has trouble testing timestamps. I tried replying there, but don't think my answer got through so here it is.

The short answer: all time operations should not use the wall clock, but a system time object that you create. In production, the system time object returns wall clock time. In testing, you replace/mock/modify it so that it uses the time you give it. In other words, it becomes a clock that you can freeze and re-set whenever you need to.

Here is an example SystemClock class in Ruby. All of your classes should use this class to get the current date and time instead of using the Time or Date classes directly.

# Keeper of the current system time. This class is sometimes overridden during
# testing to return different times.
class SystemClock

  def self.date
    Date.today
  end

  def self.time
    Time.new
  end

end

Here is the mock used during testing. You can set the time using this mock object and it will not change until it is set again.

# This mock lets you set the system date and time during testing. For example,
# to set the date to tomorrow,
#
# A few examples of use:
#
#   SystemClock.date = Date.today + 1 # Time also set: to tomorrow 9:10:11 am
#   SystemClock.date = nil            # Go back to using system date and time
#   SystemClock.date = Date.civil(2006, 4, 15)
#   SystemClock.time = Time.local(2006, 3, 2, 8, 42, 42) # Date changed, too
#
# When you set the date, the time is set to 9:10:11 am of the same day, local
# time. When you set the time, the date is set to the same day.

require 'models/system_clock'

class SystemClock

  @mock_date = nil
  @mock_time = nil

  def self.reset
    @mock_date = nil
    @mock_time = nil
  end

  def self.date=(date)
    @mock_date = date
    @mock_time = date == nil ? nil :
      Time.local(date.year, date.month, date.day, 9, 10, 11)
  end

  def self.time=(time)
    @mock_time = time
    @mock_date = time == nil ? nil :
      Date.civil(time.year, time.month, time.day)
  end

  def self.date
    @mock_date || Date.today
  end

  def self.time
    @mock_time || Time.new
  end

end

So in your normal production code you get the current time by calling SystemClock.time instead of Time.now. In your test code, you'd do something like this:

# Make sure to include the mock class
SystemClock.time = Time.now # Or any arbitrary time
thing = Thing.new           # Uses SystemClock to set created_at attribute
assert_equal SystemClock.time, thing.created_at

3 comments:

yonkeltron said...

Thank you for taking the time to give me this example! I have parsed and understood your solution but am slightly troubled by the need to alter my production code by adding mocking capabilities to make it more testable. Perhaps there is some aspect I have failed to grok?

Jim Menard said...

The mock code doesn't go in your production code. Those two definitions of SystemClock are in separate files. The first one goes in your models directory (if you are using Rails) and the second definition goes into the file tests/mock/test/system_clock.rb so it will only be seen by/used by your test code. Otherwise if you are not using Rails, just require the second definition in your test code only.

Jim Menard said...

However you are right: you need to change your production code to use SystemClock.time instead of Time.now for this to work.