ManualScheduler
ManualScheduler is a Scheduler implementation where time
doesn’t advance on its own. Schedule a 5-second timer; nothing
fires until you explicitly call scheduler.advance(5_000). The
test runs in deterministic virtual time, with no real-clock
dependencies — no flakey “sleep for long enough” patterns.
import { TestKit } from 'actor-ts/testkit';
const { kit, scheduler } = TestKit.withManualScheduler();const probe = kit.createTestProbe();
const ref = kit.system.spawnAnonymous(() => new Heartbeat(probe));ref.tell({ kind: 'start' });
scheduler.advance(5_000); // virtual time jumps 5 secondsawait probe.expectMessage('tick');
await kit.shutdown();Heartbeat schedules a tick via
context.timers.startSingleTimer('tick', tickMessage, 5_000). Real
time never elapses; the advance(5_000) fires the timer.
Why it matters
Section titled “Why it matters”// Without ManualScheduler:ref.tell({ kind: 'start' });await new Promise(r => setTimeout(r, 5_100)); // hope real time elapsedawait probe.expectMessage('tick');That works most of the time. Then CI runs slower; the test
sleeps for 5.1 s but the scheduler fires at 5.0 s vs the
expectMessage checking at 5.1 s — flakey. Or your local machine is
fast; the test waits 5.1 s every time even though the timer
fires at 5.0 s — slow.
Widening the tolerance doesn’t rescue it either, because timers
also fire early. Platform timer quanta are coarse — Windows’
default is 15.625 ms — and an event loop that checks deadlines on
the tick boundary can consider a timer due before its nominal
delay has passed: a 30 ms setTimeout measured as low as 18.7 ms
on Bun/Windows, and as high as 201 ms under CPU load. One nominal
delay, an order of magnitude of spread. No wall-clock bound is
both tight enough to mean something and loose enough to hold, and
a more precise clock doesn’t help — the timer is early, not the
measurement.
ManualScheduler removes the dependency on real time. Tests are
fast (no waits) and deterministic (advance fires timers in order).
The API
Section titled “The API”class ManualScheduler extends Scheduler { now(): number; // current virtual time (ms) advance(durationMs: number): void; // jump forward, fire matured timers advanceToNext(): void; // jump to the next pending timer's fire time pendingCount: number; // non-cancelled scheduled tasks}Inherited from Scheduler:
scheduleOnce(delayMs, target, message, sender?): CancellablescheduleOnceFunction(delayMs, task): CancellablescheduleAtFixedRate(initialDelay, interval, target, message, sender?): CancellablescheduleAtFixedRateFunction(initialDelay, interval, task): CancellableReal-time setTimeout is not used. Every schedule is a
virtual-time task that only fires via advance.
Using it via TestKit
Section titled “Using it via TestKit”const { kit, scheduler } = TestKit.withManualScheduler('my-spec');Two values:
kit— a normalTestKitwhosesystemhas the manual scheduler wired in.scheduler— the manual scheduler. Calladvanceon this to fire timers.
When the actor under test uses context.timers.startSingleTimer
or system.scheduler.scheduleOnce, the framework routes through
the system’s scheduler — which here is the manual one. Virtual
time controls all actor timers.
Using it without TestKit
Section titled “Using it without TestKit”import { ActorSystem, ActorSystemOptions, ManualScheduler } from 'actor-ts';
const scheduler = new ManualScheduler();const actorSystemOptions = ActorSystemOptions.create().withScheduler(scheduler);const system = ActorSystem.create('my-spec', actorSystemOptions);
// ... test code ...
await system.terminate();Same idea — pass the manual scheduler to ActorSystem.create via
the settings.
Advancing time
Section titled “Advancing time”scheduler.advance(1_000); // jump 1 second, fire timers maturing in that windowscheduler.advanceToNext(); // jump straight to the next pending timer and fire itscheduler.now(); // → current virtual timeadvance(ms) is the common case. Tasks scheduled to fire in the
advanced window run in order, sequentially, before advance
returns.
advanceToNext() fast-forwards straight to the fire time of the
next pending timer (and fires it), skipping any idle gap — handy
when you don’t want to compute the exact delay by hand. It is a
no-op when nothing is scheduled.
Repeating timers
Section titled “Repeating timers”ref.tell({ kind: 'start-heartbeat' }); // schedules every 1sscheduler.advance(3_500);// → fires at virtual 1000, 2000, 3000 (three times)A scheduleAtFixedRate task fires every interval as virtual
time crosses each multiple. advance(3500) from t=0 fires the
task three times.
Order matters
Section titled “Order matters”Inside one advance, tasks fire in their scheduled order
(earliest first; ties broken by insertion order). After firing a
task, if it schedules further tasks (e.g., a heartbeat
re-arms), those are added to the queue and fire when their
schedule time falls within the same advance window.
A practical test pattern
Section titled “A practical test pattern”import { describe, it, beforeEach, afterEach } from 'bun:test';import { TestKit, type ManualScheduler } from 'actor-ts/testkit';
describe('Session expiry', () => { let kit: TestKit; let scheduler: ManualScheduler; let probe: TestProbe;
beforeEach(() => { const setup = TestKit.withManualScheduler(); kit = setup.kit; scheduler = setup.scheduler; probe = kit.createTestProbe(); });
afterEach(async () => { await kit.shutdown(); });
it('expires after 15 minutes of inactivity', async () => { const session = kit.system.spawnAnonymous(() => new Session(probe, 'user-42'));
session.tell({ kind: 'activity' }); // resets the timer scheduler.advance(14 * 60_000); await probe.expectNoMessage(0); // not yet expired
scheduler.advance(60_000 + 1); // crosses 15 min await probe.expectMessage({ kind: 'session-expired', userId: 'user-42' }); });});The test runs in microseconds of wall-clock time but exercises a 15-minute idle timeout precisely.
Interaction with await and microtasks
Section titled “Interaction with await and microtasks”ref.tell({ kind: 'start' });scheduler.advance(5_000);await probe.expectMessage('tick');Two-stage processing:
advancefires the timer synchronously — ittells the actor.- The
tellis processed by the dispatcher on the next microtask turn, which runs when youawaitsomething.
expectMessage returns a Promise that does await internally — so by
the time it resolves, the tick has been processed and the message
is in the probe’s buffer.
You generally don’t need to think about this. But if you reach
for probe.messageCount synchronously right after advance and
see 0, the actor hasn’t been dispatched yet — await
something to yield.
Pitfalls
Section titled “Pitfalls”Where to next
Section titled “Where to next”- TestKit — the facade with
withManualScheduler(). - Timers and scheduling — the timer API the scheduler powers.
- Receive timeout — another time-driven mechanism that benefits from virtual time.
- Testing overview — the bigger picture.
The ManualScheduler API
reference covers the full surface.
