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.actorOf(Props.create(() => new Heartbeat(probe)));ref.tell({ kind: 'start' });
scheduler.advance(5_000); // virtual time jumps 5 secondsawait probe.expectMsg('tick');
await kit.shutdown();Heartbeat schedules a tick via
context.timers.startSingleTimer('tick', tickMsg, 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.expectMsg('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
expectMsg 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.
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 advanceTo(absoluteMs: number): void; // jump to a specific virtual time pendingCount: number; // non-cancelled scheduled tasks}Inherited from Scheduler:
scheduleOnce(delayMs, target, message, sender?): CancellablescheduleOnceFn(delayMs, fn): CancellablescheduleAtFixedRate(initialDelay, interval, target, message, sender?): CancellablescheduleAtFixedRateFn(initialDelay, interval, fn): 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, ManualScheduler } from 'actor-ts';
const scheduler = new ManualScheduler();const system = ActorSystem.create('my-spec', { scheduler });
// ... 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.advanceTo(5_000); // jump to virtual time = 5000msscheduler.now(); // → 5000advance(ms) is the common case. Tasks scheduled to fire in the
advanced window run in order, sequentially, before advance
returns.
advanceTo(absoluteMs) is for when you’ve recorded a target
time and want to fast-forward to it (useful for testing
“after-N-of-something” patterns).
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.actorOf(Props.create(() => 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.expectMsg({ 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.expectMsg('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.
expectMsg 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.