Testing With Time-Provider
The pattern across all four strategies: production code depends on ITimeProvider<TDate> (or the narrower IClock/IParser/IScheduler facets), never on Date.now()/setTimeout directly. Tests construct the same object with a different strategy.
ts
class RetryJob {
constructor(private readonly timeProvider: ITimeProvider<Date>) {}
scheduleRetries(times: number, everyMs: number, onRetry: () => void) {
let count = 0;
const handle = this.timeProvider.scheduler.setInterval(() => {
onRetry();
if (++count >= times) this.timeProvider.scheduler.clearInterval(handle);
}, everyMs);
}
}
// test
import { createTimeProvider } from "@time-provider/core/deterministic";
import { plugin } from "@time-provider/plugin-native/deterministic";
const timeProvider = createTimeProvider
.for(plugin)
.asManual()
.withInitialTime("2026-01-01T00:00:00.000Z")
.create();
const onRetry = vi.fn();
new RetryJob(timeProvider).scheduleRetries(3, 1000, onRetry);
timeProvider.clock.advance({ seconds: 3 });
expect(onRetry).toHaveBeenCalledTimes(3); // no await, no fake-timer setup/teardownWhy this beats global fake timers
- Scoped, not global. Only the
ITimeProviderinstance you built is fake — any other code touchingDate/setTimeoutin the same test process is unaffected. - No install/restore step. There's no
jest.useFakeTimers()/jest.useRealTimers()pair to remember, and nothing leaks between tests if you forget to clean up. - Deterministic by construction. Manual and sequential runs are synchronous, so assertions after
advance()don't needawaitor a microtask flush.
Choosing a strategy for a given test
- Asserting a single timestamp got stamped correctly → Fixed.
- Asserting on retry/backoff/interval behavior over elapsed time → Manual.
- Asserting on a sequence of reads from the same code path (e.g.
createdAtthenupdatedAt) → Sequential.