Removal of timecop ⏲ 👮🏼


(Sam Saffron) #1

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.