Skip to content
Rémy Duthu Rémy Duthu
May 23, 2026 · 5 min read

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:

  1. It depends on Timecop being loaded. If a spec uses Time.stub(:now) or some other time-mocking approach, the global reset does nothing.
  2. 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 before hook 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.

Test Insights

Tired of flaky tests blocking your pipeline?

Test Insights detects flaky tests, quarantines them automatically, and tracks test health across your suite.

Try Test Insights

Recommended posts

Testing

Playwright storageState is not just a setup file. It is a contract.

May 21, 2026 · 5 min read

Playwright storageState is not just a setup file. It is a contract.

Why a single test that re-saves the auth state file poisons every later test that uses it, the per-test path pattern that prevents the leak, and when cy.session-style validation is the right answer.

Rémy Duthu Rémy Duthu
Testing

Vitest's isolate:false buys you 30% speed and a class of flake you cannot grep for

May 19, 2026 · 6 min read

Vitest's isolate:false buys you 30% speed and a class of flake you cannot grep for

Why disabling per-test module isolation creates cross-file leaks that look identical to logic bugs, what the failure modes actually look like, and the audit pass that lets you keep the speed.

Rémy Duthu Rémy Duthu
Testing

Hypothesis is not flaky. Your code under test is, and Hypothesis is the messenger.

May 17, 2026 · 5 min read

Hypothesis is not flaky. Your code under test is, and Hypothesis is the messenger.

Why a property test that passed for months suddenly fails on a CI run with an example you cannot reproduce locally, the @example pattern that pins the failing case, and why the right response is never to delete the test.

Rémy Duthu Rémy Duthu