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:
- 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 - Rifle through all your code and change every
Time.current
,Time.zone.now
,Date.today
, SQLnow()
, and datetime column default value to instead rely onNow.time
andNow.date
- Make sure your tests reset the clock to the real time after they run
- 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.