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
SKILL.mdplugins/qa-time/skills/fake-clock-testing/SKILL.md
GitHubfake-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:
| Family | How it works | Libraries |
|---|---|---|
| Injection | Production code takes a clock dependency; tests pass a fake | java.time.Clock / InstantSource (JVM), TimeProvider / FakeTimeProvider (.NET) |
| Patching | The library rewrites the runtime's time APIs in test scope | freezegun (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
| Stack | Tool | Reference |
|---|---|---|
| Python (pytest / unittest) | freezegun | references/python.md |
| JS/TS in Jest | jest.useFakeTimers() (wraps Sinon's engine) |
references/js.md |
| JS/TS in Mocha / Vitest / AVA / node:test / browser | @sinonjs/fake-timers directly |
references/js.md |
| Ruby / Rails | timecop | references/ruby.md |
| Java / Kotlin / Scala | Clock.fixed / MutableClock / InstantSource injection |
references/jvm.md |
| C# / F# (.NET 8+) | TimeProvider + FakeTimeProvider |
references/dotnet.md |
| C/C++, closed-source or multi-process binaries | libfaketime (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:
- Freeze - pin now() to a fixed instant; successive reads are equal
(
freeze_time,Timecop.freeze,Clock.fixed,jest.setSystemTimeafteruseFakeTimers). - Advance - move the frozen clock forward by a duration, firing any
timers that come due (
jest.advanceTimersByTime,clock.tick,FakeTimeProvider.Advance,freezer.tick). - 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. - 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-pattern | Why it fails | Fix |
|---|---|---|
Real sleep() inside a frozen-clock test |
Sleep is wall-clock; the frozen clock never advances - the test just gets slower | Advance the fake clock instead (tick / Advance / advanceTimersByTime) |
| Fake clock leaking between tests | Restore skipped on assertion failure; later tests inherit frozen time and fail mysteriously | Restore in afterEach / fixture teardown, not the test body |
| Timezone-dependent assertions | new Date().toString() / datetime.now() render in host-local zone; green locally, red in CI |
Assert on UTC instants or set the zone explicitly (TZ env, tz=, SetLocalTimeZone) |
| Mixing real and fake time in one test | Real fetch / Thread.Sleep / C-extension resolves on the real clock; races with faked timers |
Fake 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 boundaries | Freeze a full ISO-8601 instant with offset |
Hardcoded timestamps that age (assert year == 2026) |
Test rots on the next New Year | Derive expectations from the frozen instant |
| Advancing years via timer ticks | Every intermediate timer fires; test crawls | Set / jump to the target instant instead |
| Asserting durations from the wall clock | Frozen wall clock breaks elapsed-time math | Use 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.UtcNowinternally 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-referencereferences/leap-seconds.md.
References
- Per-library recipes: references/python.md (freezegun), references/js.md (Jest + Sinon fake timers), references/ruby.md (timecop), references/jvm.md (Clock / InstantSource), references/dotnet.md (TimeProvider / FakeTimeProvider), references/libfaketime.md (LD_PRELOAD).
- Companion catalog:
dst-transition-reference(DST bug classes + leap-second reference). - Test-matrix builder:
timezone-test-matrix-builderwires these fakes into a per-touchpoint timezone/DST matrix.