TIL: Timecop has a safe mode

April 9, 2022

We use Timecop at work as a means of easily mocking the current date and being able to traverse around to simulate shifts in time. It's been an indispensable tool for testing out some particularly complex, time-sensitive components of our product.

Something a little inconsistent throughout the codebase has been people's preference between using Timecop's block or not. The latter relies on the developer to put time back to normal after they're done.

Lost in Time

Given that both #freeze and #travel mess with the app's understanding of what Time.now is at any given moment, what happens if you don't return time to normal?

This recently popped up as the cause of a few flakey tests that would rear their head in CI. We have a lot of tests in different areas that are sensitive to time. One, in particular, was popping up as a flake that was testing that a timestamp representing the last time a record was accessed was being updated. The flake that was popping up was failing intermittently because the timestamp was not changing, even though after some initial poking around it seemed like the actual functionality was behaving just fine otherwise.

It stood out to me that the failed assertion was seeing a very event time not being set, one that didn't involve any seconds or milliseconds. Having a quick search for that DateTime, I found another unrelated set of tests that were set up something like the following:

context 'context 1' do
before { Timecop.freeze(some_time) }
after { Timecop.return }
end
context 'context 2' do
before { Timecop.freeze(some_time) }
end

So we had a context being set up in our test suite that was freezing time, but not putting it back to normal so that frozen time was leaking out and impacting other tests.

Enter Safe Mode

With things from my last post in mind, I really didn't want this to become another thing where we were like, "you need to know about this gotcha forevermore whenever you're going to use Timecop". Luckily, a quick look at the docs showed that the library has a safe mode feature that enforces block syntax use. You can add this in your spec helper with the below:

Timecop.safe_mode = true

Adding this raised several errors in the test suite in places that were set up like the above contexts:

Timecop::SafeModeException:
Safe mode is enabled, only calls passing a block are allowed.

With the tests failing, we now had a list of tests that we needed to go and amend and use the block syntax where necessary. A basic version of what that might look like is:

it 'does something time-sensitive' do
Timecop.freeze(some_time) do
# Some test setup and assertions
end
end

At the end of your test, time will be back to the way it was without having to call Timecop.return. This can get a little monotonous if you have a lot of tests in one area that is all time-sensitive, but you can still achieve something similar to context 1 above using the setup hooks.

context 'context 1' do
around do |example|
Timecop.freeze(some_time) { example.run }
end
end

I was pretty happy with how this turned out, a rather insidious cause for flakey tests had been ironed out, and the issue of consistency between the two ways of using the library was solved by being forced to use one of them.