Timecop.freeze without Timecop.return is the textbook RSpec time bomb
Why a frozen clock in one spec can fail an unrelated spec in a different file, why ActiveSupport's travel_to/travel_back is the safer Rails alternative, and the global teardown that catches forgotten resets.
A Rails app’s session-token spec fails in CI with expected 1 hour from now (2026-05-23 14:30 UTC) but got 2026-01-09 01:00 UTC. The token was minted moments before the assertion. There is no way the timestamps could be that far apart. Except a spec that ran twenty seconds earlier, in a different file, called Timecop.freeze(Date.new(2026, 1, 1)), then Timecop.travel(8.days), and never reset the clock.
We see this pattern often enough on Mergify Test Insights that it earned its own slot in our flaky RSpec catalog. The cause is a global mutation with no teardown. The fix is a per-spec reset hook that runs even when the test forgot to add one.
What you see
RSpec.describe Invitation do
it "expires after 7 days" do
Timecop.freeze(Date.new(2026, 1, 1))
invite = Invitation.create!
Timecop.travel(8.days)
expect(invite).to be_expired
# missing Timecop.return
end
end
RSpec.describe SessionToken do
it "is valid for an hour" do
token = SessionToken.for(@user)
# Time.now is still 2026-01-09 because the previous spec never reset
expect(token.expires_at).to be_within(1.minute).of(1.hour.from_now)
# 1.hour.from_now is now 2026-01-09 01:00, not the real "now"
end
end
The invitation spec freezes the clock, asserts on expiry, and exits. The clock stays frozen for every subsequent spec on the same RSpec worker. The session-token spec computes 1.hour.from_now against the frozen clock and gets a value the assertion does not match. The token’s actual expires_at is real-time + 1 hour, the assertion compares against frozen-time + 1 hour, and they are months apart.
The frustrating part: under random spec order, the failure jumps. One CI run fails on the session token spec. Another fails on a cron-trigger spec. A third fails on a “now is between X and Y” spec in payments. The same root cause produces different symptoms because random order changes which spec runs first after the missing reset.
Why Timecop’s API encourages the bug
Timecop has two forms:
# Form 1: side-effecting
Timecop.freeze(time)
# ... do stuff ...
Timecop.return
# Form 2: block, auto-resets
Timecop.freeze(time) do
# ... do stuff ...
end
The block form is safe: the clock unfreezes when the block exits, even if an exception fires inside. The side-effecting form is dangerous: every call needs a paired Timecop.return, and exceptions skip it.
Most specs author either form by copying from elsewhere. The block form is more verbose, so the side-effecting form proliferates. After enough specs adopt it, eventually one of them forgets the Timecop.return and the bug enters the codebase.
The naive fix and why it is incomplete
RSpec.configure do |config|
config.after(:each) do
Timecop.return
end
end
A global after(:each) that resets every spec. This works as a backstop. It still has two failure modes:
- It depends on Timecop being loaded. If a spec uses
Time.stub(:now)or some other time-mocking approach, the global reset does nothing. - It does not prevent the bug from compounding. A spec that genuinely needs a frozen clock for setup may now have it reset before its
beforehook runs the real assertions. The reset is correct but the timing of the reset matters.
Both are edge cases. For most suites, the global reset is enough. For suites that mix mocking libraries, it is not.
The fix that holds
For Rails apps on 5.1 or newer, switch to ActiveSupport::Testing::TimeHelpers. It ships with Rails, has the same API as Timecop’s block form, and integrates with RSpec via a single include:
RSpec.configure do |config|
config.include ActiveSupport::Testing::TimeHelpers
config.after(:each) { travel_back } # belt-and-braces
end
In specs:
it "expires after 7 days" do
travel_to Date.new(2026, 1, 1) do
invite = Invitation.create!
travel 8.days
expect(invite).to be_expired
end
end
The block form auto-resets at exit. after(:each) catches the case where someone uses the side-effecting form. Time-related flakes go away because there is no path that leaves the clock frozen across specs.
If you are on a pre-Rails-5.1 codebase or a non-Rails Ruby app, Timecop’s block form gives you the same safety:
it "expires after 7 days" do
Timecop.freeze(Date.new(2026, 1, 1)) do
invite = Invitation.create!
Timecop.travel(8.days)
expect(invite).to be_expired
end
end
Block form + global Timecop.return in after(:each) covers both paths.
When the production code reads the clock
The reset only protects tests from each other. It does not help a spec that genuinely needs a frozen clock to set up its scenario. If Invitation#expired? calls Time.current.utc.to_date >= sent_at + 7.days, freezing the clock inside the spec is the correct setup. The bug is only that the clock outlives the spec.
For production code that uses time pervasively (billing cycles, subscription expiry, audit timestamps), inject a clock instead of reading Time.current directly:
class Invitation
def initialize(clock: Time)
@clock = clock
end
def expired?
@clock.current >= sent_at + 7.days
end
end
In specs, pass a frozen clock without touching the global:
it "expires after 7 days" do
clock = double(current: Date.new(2026, 1, 8))
invite = Invitation.new(clock: clock)
invite.sent_at = Date.new(2026, 1, 1)
expect(invite).to be_expired
end
No global mutation. No reset needed. The spec is faster and the production code is more testable. This is the real fix when time-related flakes are recurring across many specs — push the clock injection through the system, and Timecop becomes optional.
How Mergify catches this before you ship
Time-leak failures are easy to misclassify as “weird, retry it.” The failing spec has nothing wrong with it. The mutating spec passed and exited cleanly. Manual triage almost never finds the culprit on the first pass.
Test Insights tracks failures by the previous spec that ran on the same worker. When a spec fails consistently with time-related assertion errors only after a known clock-mutating spec runs, the dashboard surfaces the predecessor: “8 specs fail with expected 1 hour from now only when InvitationSpec ran first.” You see the cause on the same screen as the symptom.
Quarantine kicks in once the pattern is confirmed, so the merge queue keeps moving while you add the missing Timecop.return (or migrate to travel_to).
Stop chasing time-leak ghosts. Point Mergify at your suite — drop the native rspec-mergify gem in your Gemfile and you’re set.
More patterns like this
Timecop teardown leaks are one of the eight patterns in the flaky-tests-in-RSpec guide. The others are variants of the same theme: state that crosses specs because the cleanup did not run when expected. Order-dependent specs under random seed, database_cleaner strategy mismatches under JS-driven Capybara, lazy let versus let! surprises, Mocha stubs that forgot to verify. The blast radius varies; the shape does not.
The good news: most fixes are a single hook in the global config or a switch from Timecop to travel_to’s block form. Once the suite has the backstop, the pattern stops recurring even when individual spec authors forget.