Testland
Browse all skills & agents

fake-clock-testing

Fake clocks / freeze time in tests across every mainstream runtime: freezegun (Python), Jest fake timers + Sinon @sinonjs/fake-timers (JS/TS), timecop (Ruby), java.time.Clock / InstantSource injection (JVM), .NET TimeProvider / FakeTimeProvider, and libfaketime (LD_PRELOAD for any native binary). Covers the language-agnostic discipline - inject or patch the clock, freeze vs tick vs advance vs set-system-time semantics, teardown so fake clocks never leak between tests - plus the shared anti-pattern table (real sleep under a frozen clock, leaked clock state, timezone-dependent assertions). Per-library setup, API, and CI recipes live in references/{python,js,ruby,jvm,dotnet,libfaketime}.md. Use when tests need deterministic control of now(), timers, or timeouts in any language, or when choosing the right fake-clock tool for a stack.

Install with skills.sh (any agent)

npx skills add testland/qa --skill fake-clock-testing
View source

fake-clock-testing

Overview

Tests that read the real clock flake at midnight, on DST transitions, and on slow CI runners. The fix is always the same discipline, whatever the language: replace the clock the code under test reads, drive it explicitly, and restore it afterwards. Two mechanism families exist:

FamilyHow it worksLibraries
InjectionProduction code takes a clock dependency; tests pass a fakejava.time.Clock / InstantSource (JVM), TimeProvider / FakeTimeProvider (.NET)
PatchingThe library rewrites the runtime's time APIs in test scopefreezegun (Python), Jest fake timers + Sinon @sinonjs/fake-timers (JS), timecop (Ruby), libfaketime (libc interception, any binary)

Injection needs source control of the code under test but has no global state; patching works on unmodified code but must be scoped and torn down per test.

Choosing the tool

StackToolReference
Python (pytest / unittest)freezegunreferences/python.md
JS/TS in Jestjest.useFakeTimers() (wraps Sinon's engine)references/js.md
JS/TS in Mocha / Vitest / AVA / node:test / browser@sinonjs/fake-timers directlyreferences/js.md
Ruby / Railstimecopreferences/ruby.md
Java / Kotlin / ScalaClock.fixed / MutableClock / InstantSource injectionreferences/jvm.md
C# / F# (.NET 8+)TimeProvider + FakeTimeProviderreferences/dotnet.md
C/C++, closed-source or multi-process binarieslibfaketime (LD_PRELOAD escape hatch)references/libfaketime.md

libfaketime is the fallback when language-native fakes cannot reach the code: it intercepts libc time() / gettimeofday() / clock_gettime(), so it covers any dynamically linked binary regardless of language.

The four clock operations

Every library exposes some subset of the same four operations; tests should name which one they rely on:

  1. Freeze - pin now() to a fixed instant; successive reads are equal (freeze_time, Timecop.freeze, Clock.fixed, jest.setSystemTime after useFakeTimers).
  2. Advance - move the frozen clock forward by a duration, firing any timers that come due (jest.advanceTimersByTime, clock.tick, FakeTimeProvider.Advance, freezer.tick).
  3. Set / jump - reposition the clock to an absolute instant without firing intermediate timers (setSystemTime, freezer.move_to, Timecop.travel). Use for large jumps; advancing through a year fires every intermediate timer one-by-one and crawls.
  4. Restore - put the real clock back (useRealTimers, clock.uninstall, Timecop.return, decorator/context-manager exit). Always in an after-each hook, never at the end of the test body - a failed assertion would skip it and leak the fake clock into the next test.

Worked example - a boundary test on an expiring token

The canonical shape, here with .NET's FakeTimeProvider (the same freeze-then-advance pattern maps 1:1 onto every library in references/):

var fakeTime = new FakeTimeProvider(
    new DateTimeOffset(2026, 5, 20, 12, 0, 0, TimeSpan.Zero));
var svc = new TokenService(fakeTime);              // clock injected
var expiresAt = fakeTime.GetUtcNow().AddHours(1);

Assert.False(svc.IsExpired(expiresAt));            // frozen: still valid

fakeTime.Advance(TimeSpan.FromHours(1));           // advance to the boundary
Assert.False(svc.IsExpired(expiresAt));            // boundary is inclusive

fakeTime.Advance(TimeSpan.FromTicks(1));           // one tick past
Assert.True(svc.IsExpired(expiresAt));

The test asserts on both sides of the boundary and never sleeps; it passes in microseconds on any runner at any wall-clock time. In freezegun the same test is freeze_time(...) + freezer.tick(...); in Jest, setSystemTime + advanceTimersByTime; in Ruby, Timecop.freeze + a second freeze at the boundary.

Anti-patterns

Anti-patternWhy it failsFix
Real sleep() inside a frozen-clock testSleep is wall-clock; the frozen clock never advances - the test just gets slowerAdvance the fake clock instead (tick / Advance / advanceTimersByTime)
Fake clock leaking between testsRestore skipped on assertion failure; later tests inherit frozen time and fail mysteriouslyRestore in afterEach / fixture teardown, not the test body
Timezone-dependent assertionsnew Date().toString() / datetime.now() render in host-local zone; green locally, red in CIAssert on UTC instants or set the zone explicitly (TZ env, tz=, SetLocalTimeZone)
Mixing real and fake time in one testReal fetch / Thread.Sleep / C-extension resolves on the real clock; races with faked timersFake everything time-related in the test, or fake nothing
Date-only freeze (freeze_time("2026-05-20"))Interpreted as midnight local; off-by-one around zone boundariesFreeze a full ISO-8601 instant with offset
Hardcoded timestamps that age (assert year == 2026)Test rots on the next New YearDerive expectations from the frozen instant
Advancing years via timer ticksEvery intermediate timer fires; test crawlsSet / jump to the target instant instead
Asserting durations from the wall clockFrozen wall clock breaks elapsed-time mathUse the monotonic clock for durations; fake it only when the library supports it

Limitations

  • Patching libraries stop at the language boundary. C extensions, native gems, and statically linked binaries read the real clock_gettime(); use libfaketime for those (references/libfaketime.md).
  • Injection requires owning the code. Third-party libraries that call Instant.now() / DateTime.UtcNow internally cannot be reached by injected clocks.
  • Monotonic clocks are usually not faked by default (performance.now, process.hrtime, GetTimestamp); check each library's selective-faking option before asserting on them.
  • DST resolution depends on the runtime's tz database (ICU in Node, system tzdata in Python, JDK tzdata on the JVM). Pin the zone per test and assert against dst-transition-reference's documented behaviours.
  • No library simulates leap seconds - see dst-transition-reference references/leap-seconds.md.

References

.NET - TimeProvider and FakeTimeProvider

View source (opens in new window)

.NET - TimeProvider and FakeTimeProvider

.NET 8 introduced System.TimeProvider, the testable time abstraction: the production singleton TimeProvider.System wraps DateTimeOffset.UtcNow, the local TimeZoneInfo, Stopwatch timestamps, and System.Threading.Timer. Tests use FakeTimeProvider (namespace Microsoft.Extensions.Time.Testing) which subclasses TimeProvider.

Install (test projects only)

<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.*" />

TimeProvider itself is in the .NET 8+ runtime; no production package.

Inject TimeProvider

public class TokenService
{
    private readonly TimeProvider _time;
    public TokenService(TimeProvider time) => _time = time;
    public bool IsExpired(DateTimeOffset expiresAt) => _time.GetUtcNow() > expiresAt;
}

services.AddSingleton(TimeProvider.System);   // production DI

Register FakeTimeProvider, never TimeProvider.System, in test DI.

Freeze and advance

var fakeTime = new FakeTimeProvider(
    new DateTimeOffset(2026, 5, 20, 12, 0, 0, TimeSpan.Zero));   // frozen start
var svc = new TokenService(fakeTime);
var expiresAt = fakeTime.GetUtcNow().AddHours(1);

fakeTime.Advance(TimeSpan.FromHours(2));      // move forward
Assert.True(svc.IsExpired(expiresAt));

SetUtcNow(DateTimeOffset) repositions the clock; the value must not be earlier than the current fake time (throws ArgumentOutOfRangeException) - the clock cannot go backwards.

Auto-advance on every read

Per FakeTimeProvider.AutoAdvanceAmount (opens in new window): "the amount of time by which time advances whenever the clock is read."

fakeTime.AutoAdvanceAmount = TimeSpan.FromMilliseconds(100);
var t1 = fakeTime.GetUtcNow();
var t2 = fakeTime.GetUtcNow();
Assert.Equal(TimeSpan.FromMilliseconds(100), t2 - t1);

Prefer explicit Advance for tests needing exact instants.

Task.Delay and timers on the virtual clock

Delay(TimeProvider, TimeSpan, CancellationToken) is an extension in TimeProviderTaskExtensions (opens in new window); CreateTimer callbacks fire only when Advance passes the due time:

var delayTask = fakeTime.Delay(TimeSpan.FromSeconds(30));
var fired = false;
_ = delayTask.ContinueWith(_ => fired = true);

fakeTime.Advance(TimeSpan.FromSeconds(10));
await Task.Yield();                    // let continuations run
Assert.False(fired);

fakeTime.Advance(TimeSpan.FromSeconds(20));
await delayTask;
Assert.True(fired);

If a test hangs, the code under test is calling real Task.Delay(int) or Thread.Sleep instead of the injected provider - fix the injection.

Local time zone testing

fakeTime.SetUtcNow(new DateTimeOffset(2026, 3, 8, 7, 0, 0, TimeSpan.Zero));
fakeTime.SetLocalTimeZone(TimeZoneInfo.FindSystemTimeZoneById("America/New_York"));
DateTimeOffset local = fakeTime.GetLocalNow();   // UTC-5 or UTC-4 depending on DST

Per TimeProvider.GetLocalNow (opens in new window), GetLocalNow() converts the UTC instant to the provider's LocalTimeZone - no environment variables or system clock changes needed.

Migrating from ISystemClock (pre-.NET 8)

The Microsoft.Extensions stack previously used ISystemClock (opens in new window) (Microsoft.Extensions.Internal, a single UtcNow property, marked "not intended to be used directly from your code"). Migration: replace ISystemClock injection with TimeProvider and hand-rolled fakes with FakeTimeProvider - TimeProvider also covers timers and high-frequency timestamps, making it the complete replacement.

Anti-patterns

Anti-patternWhy it failsFix
DateTime.UtcNow / DateTimeOffset.UtcNow in codeNot injectableInject TimeProvider; _time.GetUtcNow()
Static DateTime mocks via Fakes/HarmonyIL rewriting, special runnersDI with TimeProvider
SetUtcNow earlier than currentThrows ArgumentOutOfRangeExceptionAdvance, or a fresh FakeTimeProvider
No await Task.Yield() after AdvanceContinuations haven't run yetYield or await the completed task
AutoAdvanceAmount in exact-instant testsClock shifts between readsKeep the default TimeSpan.Zero
TimeProvider.System in test DIWall-clock flakeRegister FakeTimeProvider

Limitations

  • Task.Delay(int) overloads without a TimeProvider still use wall-clock time; always use the timeProvider.Delay(TimeSpan) form.
  • Thread.Sleep is not controlled; restructure to await timeProvider.Delay(...).
  • GetTimestamp() values derive from the fake UTC instant, not Stopwatch (per TimestampFrequency (opens in new window)).
  • Third-party libraries calling DateTime.UtcNow internally are unaffected.

References

JavaScript / TypeScript - Jest fake timers and Sinon @sinonjs/fake-timers

View source (opens in new window)

JavaScript / TypeScript - Jest fake timers and Sinon @sinonjs/fake-timers

Both share one engine: per jestjs.io/docs/timer-mocks (opens in new window), Jest 27+ uses modern fake timers built on @sinonjs/fake-timers (opens in new window). Use Jest's wrapper inside Jest; use the Sinon library directly in Mocha, Vitest, Jasmine, AVA, node:test, or the browser. The API differs only in naming: jest.advanceTimersByTime vs clock.tick, jest.setSystemTime vs clock.setSystemTime.

Jest - enable, advance, restore

beforeAll(() => {
  jest.useFakeTimers();
  jest.setSystemTime(new Date('2026-05-20T14:30:00Z'));
});
afterAll(() => jest.useRealTimers());

test('debounce fires after 300ms', () => {
  let fired = false;
  setTimeout(() => { fired = true; }, 300);

  jest.advanceTimersByTime(299);
  expect(fired).toBe(false);
  jest.advanceTimersByTime(1);
  expect(fired).toBe(true);
});

Async chains need the Async variant so microtasks drain between ticks:

await jest.advanceTimersByTimeAsync(100);

jest.runAllTimers() drains every pending timer (recursion included); jest.runOnlyPendingTimers() runs the currently-queued set only - use it for self-rescheduling code to avoid infinite loops.

Sinon fake-timers - install, tick, restore

import FakeTimers from '@sinonjs/fake-timers';   // npm i -D @sinonjs/fake-timers

const clock = FakeTimers.install({ now: new Date('2026-05-20T14:30:00Z').getTime() });

clock.tick(1000);                                // sync advance
await clock.tickAsync(300);                      // advance + drain microtasks
clock.setSystemTime(new Date('2027-01-01T00:00:00Z'));  // jump, no timers fire

clock.uninstall();                               // ALWAYS in afterEach

Selective faking

Keep real performance.now() / nextTick while faking timers and Date:

// Jest
jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'],
                     now: new Date('2026-05-20T14:30:00Z').getTime() });
// Sinon
const clock = FakeTimers.install({ toFake: ['setTimeout', 'setInterval', 'Date'] });

DST tests

Both fake UTC time; for local-zone DST behaviour set the runtime zone first, then position the clock at the transition's UTC instant:

process.env.TZ = 'America/New_York';
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-03-08T06:30:00Z'));  // 02:30 local - non-existent
expect(new Date().toString()).toMatch(/03:30/);        // Node normalises

Reset process.env.TZ per test - it is process-global.

Fake timers + mocked fetch

A real fetch resolves on the real clock and races faked timers - mock it and await an async advance:

test('debounce + fetch', async () => {
  global.fetch = jest.fn().mockResolvedValue({ json: () => ({ ok: true }) });
  myDebouncedFetch();
  await jest.advanceTimersByTimeAsync(300);
  expect(fetch).toHaveBeenCalled();
});

Anti-patterns

Anti-patternWhy it failsFix
jest.useFakeTimers('legacy')Deprecated; doesn't fake DateModern is the default since Jest 27
Forget useRealTimers / clock.uninstallLater tests inherit the fake clockafterEach hook
Sync tick / advanceTimersByTime for promise chainsMicrotasks don't draintickAsync / advanceTimersByTimeAsync
Skip setSystemTime, then read DateDate.now() returns real timeAlways position the clock
tick(86400 * 365 * 1000) to "advance a year"Every timer fires one-by-one; crawlssetSystemTime jump
DST test without process.env.TZUTC-only; the local-zone branch never runsSet TZ explicitly per test

Limitations

  • Monotonic sources (performance.now, process.hrtime) are only faked when requested (toFake / defaults vary) - check before asserting.
  • doNotFake is fragile: some helpers internally read Date.now().
  • DST + TZ resolution depends on the runtime's ICU data (Node) or the browser's tz tables.

References

JVM - java.time.Clock and InstantSource injection

View source (opens in new window)

JVM - java.time.Clock and InstantSource injection

The JVM has no "freeze clock" library because java.time (Java 8+) was designed with dependency-injected Clock as the testing pattern. Per docs.oracle.com Clock (opens in new window): "Most application code should inject a Clock into any method that needs the current instant and date/time." Production injects Clock.systemDefaultZone(); tests inject Clock.fixed(...). No global monkey-patching.

The injection pattern

public class TaskScheduler {
    private final Clock clock;
    public TaskScheduler(Clock clock) { this.clock = clock; }
    public Task scheduleNext(Duration after) {
        return new Task(Instant.now(clock).plus(after));
    }
}
// production wiring
TaskScheduler prod = new TaskScheduler(Clock.systemDefaultZone());

Clock.fixed (frozen)

@Test
void scheduleNext() {
    Clock fixed = Clock.fixed(Instant.parse("2026-05-20T14:30:00Z"),
                              ZoneId.of("America/New_York"));
    Task task = new TaskScheduler(fixed).scheduleNext(Duration.ofMinutes(5));
    assertEquals(Instant.parse("2026-05-20T14:35:00Z"), task.getScheduledAt());
}

Clock.fixed never advances - successive Instant.now(fixed) calls return the same value.

Clock.offset (relative) and a mutable test clock

Clock realPlus10 = Clock.offset(Clock.systemDefaultZone(), Duration.ofMinutes(10));

For advance-mid-test semantics, a small custom clock:

public class MutableClock extends Clock {
    private Instant instant;
    private final ZoneId zone;
    public MutableClock(Instant instant, ZoneId zone) { this.instant = instant; this.zone = zone; }
    public void setInstant(Instant i) { this.instant = i; }
    public void advance(Duration d) { instant = instant.plus(d); }
    @Override public Clock withZone(ZoneId z) { return new MutableClock(instant, z); }
    @Override public ZoneId getZone() { return zone; }
    @Override public Instant instant() { return instant; }
}

InstantSource (Java 17+)

Per docs.oracle.com InstantSource (opens in new window), a narrower interface than Clock (just instant(), no zone) - prefer it when code only needs the instant; a lambda is a complete fake:

InstantSource fake = () -> Instant.parse("2026-05-20T14:30:00Z");

Spring DI integration

@Configuration
public class ClockConfig {
    @Bean public Clock clock() { return Clock.systemDefaultZone(); }
}

@TestConfiguration
public class TestClockConfig {
    @Bean public Clock clock() {
        return Clock.fixed(Instant.parse("2026-05-20T14:30:00Z"), ZoneOffset.UTC);
    }
}

DST tests

Clock fixed = Clock.fixed(Instant.parse("2026-03-08T07:30:00Z"),  // 02:30 local - non-existent
                          ZoneId.of("America/New_York"));
ZonedDateTime zdt = ZonedDateTime.ofInstant(fixed.instant(), fixed.getZone());
// assert per the resolver rules documented in dst-transition-reference

Anti-patterns

Anti-patternWhy it failsFix
Instant.now() / System.currentTimeMillis() directlyNot injectableInject Clock; Instant.now(clock)
Static-mocking Clock with PowerMockBrittle bytecode rewritingUse DI
No zone in Clock.fixedDefaults matter; local-time tests degenerateAlways pass the zone
Only frozen clocks, never advancingDuration arithmetic untestedMutableClock.advance
Multiple Clocks per serviceCoordination bugsOne Clock per service

Limitations

  • Requires source control - libraries calling Instant.now() internally aren't reachable; libfaketime partially applies but some JVM time calls bypass libc (libfaketime.md (opens in new window)).
  • Thread.sleep is real time - use a controllable ScheduledExecutorService for schedule-driven code.

References

libfaketime - the LD_PRELOAD escape hatch

View source (opens in new window)

libfaketime - the LD_PRELOAD escape hatch

Per github.com/wolfcw/libfaketime (opens in new window), libfaketime returns a value derived from the FAKETIME environment variable instead of the real clock by intercepting libc time() / gettimeofday() / clock_gettime(). Because it hooks libc, it works for any dynamically linked binary - C/C++, Go (cgo builds), Rust, Python - including processes you don't control the source of. Reach for it when language-native fakes cannot patch the code (C extensions, closed-source binaries, multi-process integration tests).

Install

sudo apt install faketime        # Debian/Ubuntu
brew install libfaketime         # macOS
# or from source: git clone https://github.com/wolfcw/libfaketime && make && sudo make install

Absolute-date mode

faketime '2026-12-31 23:59:00' your_command
# equivalent raw form:
LD_PRELOAD=/usr/local/lib/faketime/libfaketime.so.1 \
  FAKETIME='2026-12-31 23:59:00' your_command

Relative offset and advance-rate modes

faketime '-1d' your_command                       # 1 day in the past
faketime '+2h30m' your_command                    # 2h30m ahead
faketime -f '@2026-12-31 23:59:00 x10' your_cmd   # start there, run at 10x speed

The x<rate> spec suits scheduler/cron simulations - e.g. faketime -f '@2026-01-01 00:00:00 x5256' ./cron-runner simulates a year in ~10 minutes.

High-resolution mode

FAKETIME_NO_CACHE=1 faketime '2026-12-31 23:59:00' your_command

Disables libfaketime's per-second caching so code reading time hundreds of times per second sees consistent values.

Asserting from a test runner

libfaketime emits nothing itself - assert on the wrapped program's visible behaviour:

import subprocess

def test_cron_fires_at_midnight():
    result = subprocess.run(
        ["faketime", "2026-12-31 23:59:30", "./cron-runner"],
        capture_output=True, text=True, timeout=5,
    )
    assert "Fired at 2027-01-01 00:00:00" in result.stdout

DST recipe - non-existent local time

TZ='America/New_York' faketime '2026-03-08 02:30:00' ./my-program

US Eastern springs forward at 02:00 on 2026-03-08, so 02:30 local does not exist; TZ makes the program resolve the faked instant in Eastern. Assert the program skips the job or normalises to 03:30, per dst-transition-reference.

CI integration

jobs:
  time-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: sudo apt-get install -y faketime
      - run: pytest tests/time/

Anti-patterns

Anti-patternWhy it failsFix
Statically linked binariesLD_PRELOAD has no symbols to interceptLanguage-native fake clock
Raw LD_PRELOAD with a wrong pathSilently no-opsUse the faketime wrapper
Spring-forward test without TZFake time resolves in UTC onlyPrefix TZ='<zone>'
Missing FAKETIME_NO_CACHE=1 for fast-polling codeTime stalls between cache refreshesSet it explicitly
Using it against the JVMSome JVM time calls bypass libcClock injection (jvm.md (opens in new window))

Limitations

  • Linux + macOS only - Windows uses different time syscalls.
  • Static binaries unaffected - Go compiled with CGO_ENABLED=0 does not see libfaketime.
  • Monotonic clocks are not faked by default; some clock_gettime flags pass through.

References

Python - freezegun

freezegun patches datetime.datetime, datetime.date, time.time, time.gmtime, time.localtime, time.strftime, and asyncio time across the test scope. Per github.com/spulec/freezegun (opens in new window).

Install

pip install freezegun

Decorator (most common)

from freezegun import freeze_time
from datetime import datetime

@freeze_time("2026-05-20T14:30:00")
def test_today_is_may_20():
    assert datetime.now().strftime("%Y-%m-%d") == "2026-05-20"

Context manager and manual start/stop

with freeze_time("2026-05-20T14:30:00"):
    assert datetime.now().strftime("%Y-%m-%d") == "2026-05-20"

freezer = freeze_time("2026-05-20T14:30:00")
freezer.start()
try:
    ...
finally:
    freezer.stop()

Tick mode and moving mid-test

@freeze_time("2026-05-20T14:30:00", tick=True)   # real time passes from the frozen start
def test_clock_advances():
    t1 = datetime.now()
    t2 = datetime.now()
    assert t2 > t1

@freeze_time("2026-05-20T14:30:00")
def test_advance_one_day(freezer):
    freezer.move_to("2026-05-21T14:30:00")       # or freezer.tick(delta=timedelta(hours=24))
    assert datetime.now().day == 21

Timezone offset

@freeze_time("2026-05-20T14:30:00", tz_offset=-5)
def test_eastern_time():
    assert datetime.utcnow().hour == 19   # 14:30 + 5
    assert datetime.now().hour == 14      # wall clock

tz_offset is a fixed offset and does not know about DST. For local-zone behaviour at a transition, freeze UTC and read through zoneinfo:

from zoneinfo import ZoneInfo

@freeze_time("2026-03-08T07:30:00")   # 02:30 EST or 03:30 EDT depending on resolution
def test_spring_forward_handling():
    ny = datetime.now(ZoneInfo("America/New_York"))
    # assert against the documented library behaviour per dst-transition-reference

Async support

@freeze_time("2026-05-20T14:30:00")
async def test_async_now():
    await asyncio.sleep(0)
    assert datetime.now().strftime("%Y") == "2026"

Per freezegun docs: "freezegun is compatible with asyncio."

CI integration

jobs:
  python-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v5
      - run: pip install -e ".[test]" freezegun
      - run: pytest tests/

Anti-patterns

Anti-patternWhy it failsFix
freeze_time("2026-05-20") (date only)Interpreted as midnight local; subtleUse a full ISO datetime
time.sleep(...) inside a frozen blockSleep is real-time; frozen clock doesn't advanceUse freezer.tick()
Mock datetime.utcnow separatelyConflicts with freezegunLet freezegun patch both
Forget freezer cleanup in fixturesCross-test contaminationUse decorator or with
DST test without tz_offset or zoneinfoResult is UTC; misses local behaviourCombine with zoneinfo
@freeze_time on a class without decorate_class=TrueMethods not patchedUse the class decorator explicitly

Limitations

  • C extensions bypass freezegun - a library calling clock_gettime() from C sees the real clock. Use libfaketime (libfaketime.md (opens in new window)).
  • tz_offset doesn't know about DST - use datetime.now(tz=zoneinfo.ZoneInfo(...)) for accurate local-zone tests.
  • Module-level from datetime import datetime at import time can cache the unfrozen callable before the patch lands.

References

Ruby - timecop

timecop is the canonical Ruby time-mocking gem. Per github.com/travisjeffery/timecop (opens in new window), it patches Time.now, Date.today, DateTime.now, and Time.new.

Install

# Gemfile
group :test do
  gem 'timecop'
end

Timecop.freeze (snapshot) vs Timecop.travel (clock continues)

Timecop.freeze(Time.local(2026, 5, 20, 14, 30)) do
  expect(Time.now.strftime('%Y-%m-%d')).to eq('2026-05-20')
end  # auto-restored after the block

Timecop.travel(Time.local(2026, 12, 31, 23, 59, 0)) do
  sleep 5  # real sleep; travel keeps the clock ticking from the offset
  expect(Time.now).to be_within(6.seconds).of(Time.local(2026, 12, 31, 23, 59, 5))
end

freeze pauses the clock; travel offsets it and lets it keep ticking. They are not interchangeable - use freeze when the clock must not advance.

Manual control and cleanup

Timecop.freeze(Time.local(2026, 5, 20, 14, 30))
# ... test code
Timecop.return    # restore; wrap in ensure when not using the block form

RSpec safety net:

RSpec.configure do |config|
  config.after(:each) { Timecop.return }
end

Timecop.scale (time speed-up)

Timecop.scale(3600) do            # 1 real second = 1 simulated hour
  start = Time.now
  sleep 1
  expect(Time.now - start).to be_within(60).of(3600)
end

DST tests (Rails / ActiveSupport)

Ruby Time doesn't track zones natively; use ActiveSupport's Time.zone:

require 'active_support/time'

Time.zone = 'America/New_York'
Timecop.freeze(Time.zone.local(2026, 3, 8, 2, 30, 0)) do
  # 02:30 local doesn't exist on this spring-forward date;
  # assert the documented behaviour per dst-transition-reference
end

Save and restore Time.zone per test - it is process-global config.

Rails controller example

RSpec.describe BookingController do
  it 'rejects past dates' do
    Timecop.freeze(Date.new(2026, 5, 20)) do
      post :create, params: { date: '2026-05-19' }
      expect(response.status).to eq(400)
    end
  end
end

Anti-patterns

Anti-patternWhy it failsFix
Forget Timecop.returnCross-test contaminationRSpec after-each hook
freeze + sleepSleep is real-time; the frozen clock stays putUse travel or scale
Hardcode Time.zone in testsConfig bleeds across testsSave/restore the zone per test
DST test without ActiveSupport zoneRuby Time has no zone trackingTime.zone + Time.zone.local
Date.today without a freezeTest fails at midnightAlways freeze

Limitations

  • C extensions bypass timecop - native gems calling clock_gettime aren't patched; use libfaketime (libfaketime.md (opens in new window)).
  • Time.zone (ActiveSupport) and Time can diverge - be explicit about which the code under test reads.
  • Date.parse uses the real system locale and does not honor Timecop.

References

Related skills

dst-transition-reference

Pure-reference catalog of Daylight Saving Time (DST) transition patterns and their canonical bug classes. Covers the spring-forward (skipped hour: 02:00 → 03:00 local) and fall-back (repeated hour: 02:00 → 01:00 local) transitions, the historical irregularity of DST (different jurisdictions, transitions on different dates, some regions abolish DST or never adopted it), the IANA timezone database (tz / Olson DB) as the canonical source, and the testable behaviors DST creates (duplicate / missing local timestamps, cron jobs that fire 0 or 2 times, billing periods that miss / double-count, recurring meetings on transition days). Per-jurisdiction DST-rule tables, refreshable per-region test-data fixtures, and the leap-second reference (23:59:60 insertion, time_t stalls, leap-smear vs step, monotonic-clock fixes) live in references/. Use when designing or auditing time-handling code or test cases, or when auditing leap-second assumptions.

timezone-test-matrix-builder

Builds a timezone, daylight saving time (DST), and leap year / leap second test matrix from wherever a codebase reads or formats dates and times. Finds time-handling code (grep for datetime / Date / Instant / time.time / timezone), sorts each spot into storage, business-logic, display, cron, or billing, picks the edge cases that matter (DST spring-forward / fall-back, ambiguous local time, leap day Feb 29, ISO 8601 / RFC 3339 round-trip, zone-database updates), and emits per-spot test stubs wired to the language's fake-clock (mock-time) library. Use when a codebase needs timezone, DST, and leap-year test coverage derived from its own date/time usage.