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
, andDateTime.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:
Timecop.freeze
to freeze time (which optionally accepts a block),Timecop.return
to unfreeze time,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
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:
- Find all occurrences of
timecop
methods in test files.
We found several places where time had been frozen but wasn’t unfrozen afterwards 👮 - Replace them by equivalent from the
TimeHelpers
module. - Remove
timecop
fromGemfile
✂️. - Have one external dependency less :-).
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 🙂