justin․searls․co

How to control Time in Ruby on Rails

Faking time is a frequent topic of conversation in software testing, both because the current time & date influence how many programs should behave and because reading a real system clock can expose edge cases that make tests less reliable (e.g. starting a build just before midnight on New Year's Eve may see assertions fail with respect to what year it is).

I've approached this issue a dozen different ways over the years, and there are a number of tools and practices promoted in every tech stack. Rubyists often lean on the timecop gem and Active Support's TimeHelpers module to manipulate Ruby's time during testing. Regardless, no tool-based solution is robust enough to cover every case: unless the operating system, the language runtime, the database, and every third-party service agree on what time it is, your app is likely to behave unexpectedly.

Put differently, the myriad ways that programmers have to check the time means it's not sufficient to select the best available time-faking tool, because no tool can fake it perfectly everywhere. Take it from someone who's tried. Ask me how much of a success I felt like when my test runner oscillated between reporting infinite and negative test durations. Or how much fun it was when my database started spewing arcane internal data integrity failures, with no indication that the cause was my mucking with the system clock. Or for my entire computer to go offline whenever the operating system decided to refresh its certificate cache while tests were running and it appeared that every single trusted SSL certificate was expired.

And it's not just testing! Personally, I find myself wanting to fake out the current time just as often in my development environment. Reason being, the last four complex user interfaces I built were totally dependent on the answer to the question, "what time is it right now?" (Among them: a timesheet invoicing app, a timer-based memorization tool, a staffing utilization and forecasting dashboard, and a strength-training tracker.) In each case, having been able to quickly simulate how the app would behave days, weeks, or months in the future or past would have made it much easier to validate its behavior and perform exploratory testing.

Like so many things in software, the only durable solution requires a blend of thoughtful tool-making and rigid discipline. It starts with designating a single chokepoint through which your code can both override the current time and read the current time (whether real or fake). This isn't technically challenging on its face. The hard part is making absolutely sure that the code always reads the time in the same way everywhere throughout your codebase. And that might seem extreme, especially if it means forcing every contributor to eschew familiar APIs like Time.current and to pass the application time into external systems that could just as well read the time themselves (e.g. parameterizing time in every SQL query in lieu of calling functions like now()). Radical as this might sound at first, this approach is no different than treating time like one might a third-party dependency they want to keep at arm's length from their code. Like introducing an adapter layer between your app and a library's API to make it easier to swap out later. Or wrapping a framework feature in an object you own to ensure any related logging and security concerns are handled consistently.

For me, since I'm a selfish programmer working by my lonesome, the discipline part is easier than if I were on a large team in a massive organization. So I just threw together this little solution for my current app. It required only a few steps:

  1. Create a new module named Now that provides the current time and date, as well as some method for overriding the value of "now" to an arbitrary time
  2. Rifle through all your code and change every Time.current, Time.zone.now, Date.today, SQL now(), and datetime column default value to instead rely on Now.time and Now.date
  3. Make sure your tests reset the clock to the real time after they run
  4. Give yourself a way to override the clock when running your development server

Let's go through each step.

Creating a Now module

Here's my Now module:

# app/lib/now.rb
class Now
  def self.instance
    @instance ||= Now.new
  end

  def self.override!(fake_start_time)
    raise "Overriding time is not allowed in production!" if Rails.env.production?
    @instance = Now.new(fake_start_time)
  end

  def self.reset!
    @instance = Now.new
  end

  def self.time
    instance.time
  end

  def self.date
    instance.date
  end

  def initialize(fake_start_time = nil)
    @fake_start_time = fake_start_time
    @actual_start_time = Time.current
  end

  def time
    if @fake_start_time.present?
      elapsed_time = Time.current - @actual_start_time
      @fake_start_time + elapsed_time
    else
      Time.current
    end
  end

  def date
    time.to_date
  end
end

As you might be able to see, the time is faked by passing a specific time to Now.override! Importantly, Now.time will continue to allow time to elapse by way of also tracking a known start time. This way, time isn't unnaturally frozen in place once it's faked (as it is when one calls Active Support's TimeHelpers#travel method). You'll also see a guard clause preventing time from being overridden in production, because that seems like a bad idea. This implementation is only a starting point; note, for example, it is extremely not thread-safe and will fall over if you blow too hard on it.

Here's a quick test I wrote to go with it:

# test/lib/now_test.rb
require "test_helper"

class NowTest < ActiveSupport::TestCase
  def test_normal_now
    assert_in_delta Time.current, Now.time, 1.second
    assert_equal Date.current, Now.date
  end

  def test_overridden_time
    fake_start_time = Time.current - 1.month
    Now.override!(fake_start_time)

    assert_in_delta fake_start_time, Now.time, 1.second
    assert_equal fake_start_time.to_date, Now.date
    refute_equal fake_start_time, Now.time

    Now.reset!
    assert_in_delta Time.current, Now.time, 1.second
    assert_equal Date.current, Now.date
  end
end

Replace all time references

Go through your code and change all of it to rely exclusively on Now.time and Now.date.

This step is left as an exercise to the reader.

Reset time after each test

Whenever you have a test of something that cares about the current time, it can now be faked using the Now.override! method:

require "test_helper"

class MonthTest < ActiveSupport::TestCase
  # …
  def test_first_day_of_the_month
    Now.override!(Date.new(2024, 8, 30))

    assert_equal Date.new(2024, 8, 1), Month.beginning_of_this_month
  end
end

Then back in my test helper, I call Now.reset! in a teardown hook on ActiveSupport::TestCase, which will cascade down to all its descendants:

# test/test_helper.rb
# …
module ActiveSupport
  class TestCase
    # …
    teardown do
      Now.reset!
    end
  end
end

Allow development servers to run in a fake time

I could do something more sophisticated like track the current fake time in the database (my apps usually maintain a singleton SystemConfiguration model backed by a one-row table), but for my purposes, I don't mind restarting my development server each time I want to demonstrate my complete mastery over time and space.

Here's how to do it with a FAKE_NOW_TIME environment variable:

# config/initializers/fake_now_time.rb
Rails.application.config.after_initialize do
  if ENV["FAKE_NOW_TIME"] && !Rails.env.production?
    warn "⏰ Overriding time with #{ENV["FAKE_NOW_TIME"]} ⏲️"
    Now.override!(Time.zone.parse(ENV["FAKE_NOW_TIME"]))
  end
end

Extra credit

The hard part is maintaining the diligence to always reference the time through a single Now module for the life of the app. One solution to that problem would be to have a single developer (ideally, one with an obsessive attention to details like this) maintain the system in perpetuity. Another solution might be to write a couple custom RuboCop rules to enforce this.


Got a taste for hot, fresh takes?

Then you're in luck, because you can subscribe to this site via RSS or Mastodon! And if that ain't enough, then sign up for my newsletter and I'll send you a usually-pretty-good essay once a month. I also have a solo podcast, because of course I do.