How we migrated from Timecop to built-in Rails 5.2 time helpers

๐Ÿ‘‹ I am open to remote-friendly offers.

If you or your company would be interested in working with me, feel free to send me a message.

Main areas of interest: Ruby, CI, CD, DevOps, AWS, Terraform, Golang, bash, the best engineering practices ๐Ÿ‘จโ€๐Ÿ’ป

TLDR; Since Ruby on Rails 5.2 timecop gem can be replaced by built-in methods defined within the ActiveSupport::Testing::TimeHelpers module.

Please remember about unfreezing time in tests, regardless of an approach you choose.

Sooner or later each of us encounters a situation where a method depends on time. The feature needs to be tested later on. Among Rubyists, the most popular gem which provides handy helpers to this problem is called timecop:

A gem providing “time travel”, “time freezing”, and “time acceleration” capabilities, making it simple to test time-dependent code. It provides a unified method to mock Time.now, Date.today, and DateTime.now in a single call.

To better illustrate when the gem may be useful, let’s write down some naive lines of code representing a building with a clock โฐ:

class Building
  def clock
    Time.zone.now
  end
end

To cover the #clock method with rspec we could use the below lines:

describe Building do
  describe '#clock' do
    it 'returns time displayed by the clock' do
      expect(described_class.new.clock).to eq Time.zone.now
    end
  end
end

And, surprisingly or not, the test didn’t pass:

expected: 2020-03-11 19:26:14.958265000 +0000
     got: 2020-03-11 19:26:14.958228000 +0000

You know what they say โ€“ time flies when you’re having fun ๐Ÿ’ƒ.

The timecop gem provides a set of useful method to handle such cases without a need for complicated mocking time-related objects, inter alia:

  1. Timecop.freeze to freeze time (which optionally accepts a block),
  2. Timecop.return to unfreeze time,
  3. Timecop.travel to time travel.

To make the failing tests, we can freeze time:

describe Building do
  describe '#clock' do
    it 'returns time displayed by the clock' do
      Timecop.freeze do
        expect(described_class.new.clock).to eq Time.zone.now
      end
    end
  end
end

And tada ๐ŸŽ‰, the test passed:

Building
  #clock
    returns time displayed by the clock

Finished in 0.62 seconds (files took 2.63 seconds to load)
1 example, 0 failures
A common issue I have observed over time is calling Timecop.freeze without a block and forgetting about Timecop.return to put time back the way it was.

Today I learned that there is a Timecop.safe_mode method which forces using the block syntax. Otherwise Timecop::SafeModeException is raised ๐Ÿค“.

And that had been, more or less, an approach we took until Ruby on Rails 5.2 was released. According to its release note it adds freeze_timeย helper which freezes time toย Time.now in tests.

Adopting the new helper(s)

In addition to the freeze_time there are also other useful methods grouped into the ActiveSupport::Testing::TimeHelpers module. They provide the same functionalities timecop provides. To make usage of them, we had to:

  1. Find all occurrences ofย  timecop methods in test files.
    We found several places where time had been frozen but wasn’t unfrozen afterwards ๐Ÿ‘ฎ
  2. Replace them by equivalent from the TimeHelpers module.
  3. Remove timecop from Gemfile โœ‚๏ธ.
  4. Have one external dependency less :-).
The module is not included by default. To have the method available across tests files you need to include it, e.g. inside spec/rails_helpers.rb file:
config.include ActiveSupport::Testing::TimeHelpers.

To provide an example, let’s update the Building#clock? test:

include ActiveSupport::Testing::TimeHelpers

describe Building do
  describe '#clock' do
    it 'returns time displayed by the clock' do
      freeze_time do
        expect(described_class.new.clock).to eq Time.zone.now
      end
    end
  end
end

BONUS Making the code more consistent

To learn from our own mistakes, along the way we decided to unify our approach to time across all the test files. We made usage of the around RSpec hook to define a custom test helper (placed within spec/support/time_helper.rb file):

# Allow to freeze the time in scope of tagged example.
RSpec.configure do |config|
  config.around(:each, :stop_the_time) do |example|
    freeze_time do
      example.run
    end
  end
end

Thanks to it, we can simply freeze time by marking a test with :stop_the_time tag:

include ActiveSupport::Testing::TimeHelpers

# Allow to freeze the time in scope of tagged example.
RSpec.configure do |config|
  config.around(:each, :stop_the_time) do |example|
    freeze_time do
      example.run
    end
  end
end

describe Building do
  describe '#clock' do
    it 'returns time displayed by the clock', :stop_the_time do
      expect(described_class.new.clock).to eq Time.zone.now
    end
  end
end

One additional benefit comes from the above approach: it uses the block syntax so there is no need to remember about unfreezing time each time test relies on it. Just use the tag aka stay consistent.

Summary

It is a good practice to review release notes of a new version of whatever you upgrade, language, framework or library. They often include important announcements like deprecation warnings and promote new features.

Thanks to new methods provided in Ruby on Rails 5.2 we made code of our tests better and decoupled an application from external dependency. We killed two birds with one stone ๐Ÿ™‚

 

Igor Springer

I build web apps. From time to time I put my thoughts on paper. I hope that some of them will be valuable for you. To teach is to learn twice.