Yesterday I committed a change that removed the timecop gem and replaced it with these lines of code:
class TrackTimeStub
def self.stubbed
false
end
end
def freeze_time(now=Time.now)
datetime = DateTime.parse(now.to_s)
time = Time.parse(now.to_s)
if block_given?
raise "nested freeze time not supported" if TrackTimeStub.stubbed
end
DateTime.stubs(:now).returns(datetime)
Time.stubs(:now).returns(time)
Date.stubs(:today).returns(datetime.to_date)
TrackTimeStub.stubs(:stubbed).returns(true)
if block_given?
begin
yield
ensure
unfreeze_time
end
end
end
def unfreeze_time
DateTime.unstub(:now)
Time.unstub(:now)
Date.unstub(:today)
TrackTimeStub.unstub(:stubbed)
end
Migrating from timecop to our freeze_time
helper should be a breeze as it already supports the block form. So most changes are as simple as:
Timecop.freeze(1.day.from_now) do
no_longer_living_on_borrowed_time!
end
# to
freeze_time(1.day.from_now) do
no_longer_living_on_borrowed_time!
end
I manually changed every usage of Timecop in both core and core plugins to use the new pattern. It took me less than an hour.
Why was this done?
I guess the first thing people may ask when confronted with this "Sam!? Wheels why reinvent them? Cheese, why move it?
The catalyst for the change was 3 days of an unstable test suite, caused by misuse of the Timecop library. But that was not the only issue I had.
Timecop API is too wide
The timecop gem offered too many options and too much flexibility, for example, want to do:
Timecop.travel 1.day.ago do
Timecop.travel 1.day.ago do
Timecop.travel 1.day.ago do
end
end
end
Nesting can very quickly lead to incredibly hard to understand specs. Additionally, it had a bunch of fanciness we never used including date rounding, thread safe operation and time stack walking.
Timecop API required consumers to remember to “unfreeze” time, which is called return
before do
Timecop.freeze(1.day.ago)
end
after do
# forget this and you leak state into your test suite and break it
# cause time is frozen after your test
Timecop.return
end
Our API uses mocha which always cleans up, no need to remember anything.
Less dependencies make me happy
I don’t see why carry a whole gem dependency for 20 or so lines of code we control and can trivially debug. We already have a mocking library I don’t see why we need to introduce a second one.
There is no reason to have 2 time travel APIs
It is confusing to have 2 ways of mocking time. Developers should not be force to choose between 2 APIs that achieve essentially the same goal.
Additionally, Discourse uses mocha for all object mocking. You end up having 2 mocking libraries and if you mock Time
with with both you can very quickly get very unexpected results.