Testing overview
Actor systems are tricky to test naively because two things are hard from outside:
- Asynchrony — every
tellhappens later; assertions need to wait. - Time — receive timeouts, scheduled retries, gossip rounds all depend on the clock.
The framework’s TestKit gives you tools to control both:
| Tool | Solves |
|---|---|
TestKit | A ready-to-use ActorSystem with quiet logging + helper methods. |
TestProbe | A fake actor that captures messages for assertions. |
ManualScheduler | A virtual-clock scheduler — time advances only when you say so. |
MultiNodeSpec | Spin up multiple cluster nodes in one process for cluster-scenario tests. |
ParallelMultiNodeSpec | Run nodes in separate processes when isolation matters. |
The first three are for unit + integration tests of a single actor system; the last two for distributed scenarios.
A minimal test
Section titled “A minimal test”import { describe, it, expect } from 'bun:test';import { Actor, Props, TestKit } from 'actor-ts/testkit';
type Cmd = { kind: 'inc' } | { kind: 'get'; replyTo: ActorRef<number> };
class Counter extends Actor<Cmd> { private count = 0; override onReceive(cmd: Cmd): void { if (cmd.kind === 'inc') this.count++; else cmd.replyTo.tell(this.count); }}
describe('Counter', () => { it('increments and replies', async () => { const tk = TestKit.create('counter-spec'); const probe = tk.createTestProbe();
const counter = tk.system.actorOf(Props.create(() => new Counter())); counter.tell({ kind: 'inc' }); counter.tell({ kind: 'inc' }); counter.tell({ kind: 'get', replyTo: probe as ActorRef<number> });
await probe.expectMsg(2); await tk.shutdown(); });});Two things from the testkit doing the work:
TestKit.create(name)builds a fresh ActorSystem with quiet logging — no console spam during test runs.probe.expectMsg(2)asserts the next message the probe receives is exactly2, with a default 3-second timeout.
tk.shutdown() at the end is critical — without it, the test
leaves a live system + dispatcher running, and the test process
may hang.
The three layers
Section titled “The three layers”Unit — actor in isolation
Section titled “Unit — actor in isolation”const tk = TestKit.create();const probe = tk.createTestProbe();const ref = tk.system.actorOf(Props.create(() => new MyActor(probe)));ref.tell({ kind: 'do' });await probe.expectMsg('done');await tk.shutdown();MyActor is constructed with probe as a callback target — it
tells the probe in place of whatever it would tell in
production. Tests assert on what the probe sees.
This is the canonical shape — see TestKit and TestProbe.
Time-deterministic — ManualScheduler
Section titled “Time-deterministic — ManualScheduler”const { kit, scheduler } = TestKit.withManualScheduler();const probe = kit.createTestProbe();
const ref = kit.system.actorOf(Props.create(() => new ScheduledThing(probe)));ref.tell({ kind: 'start' });
scheduler.advance(5_000); // virtual time jumps 5 secondsawait probe.expectMsg({ kind: 'fired' });
await kit.shutdown();Actors using context.timers.startSingleTimer or
system.scheduler.scheduleOnce get their fires driven by the
manual scheduler — no real setTimeout, no flakiness.
See ManualScheduler.
Distributed — MultiNodeSpec
Section titled “Distributed — MultiNodeSpec”import { MultiNodeSpec } from 'actor-ts/testkit';
const spec = await MultiNodeSpec.create({ systemName: 'cluster-spec', nodes: 3,});
// `spec.nodes` is an array of ActorSystems, all joined into one cluster.const node1Probe = spec.createTestProbe(0);const remote = spec.nodes[1].actorOf(Props.create(() => new Worker()));remote.tell({ kind: 'do', replyTo: node1Probe });await node1Probe.expectMsg({ kind: 'done' });
await spec.shutdown();Three actor systems join a cluster inside one test process, with the in-memory transport. Useful for verifying sharding behaviour, cluster-singleton failover, distributed-data merge semantics — all without a Docker-Compose setup.
See MultiNodeSpec.
What to test
Section titled “What to test”Three categories:
- Pure behavior — give the actor a sequence of messages, assert on the probe. Most actor unit tests look like this.
- State + persistence — for PersistentActors, test the recovery path explicitly by stopping the actor and re-spawning it. Assert the recovered state matches what you’d expect from the event sequence.
- Distributed behavior — for cluster features, MultiNodeSpec gives you a “real-enough” cluster to test sharding placement, singleton failover, etc.
What not to test
Section titled “What not to test”TestKit vs raw ActorSystem in tests
Section titled “TestKit vs raw ActorSystem in tests”Both work. TestKit is convenience:
- Defaults to
NoopLogger— no console spam. - Provides
createTestProbe(),within(ms, fn),shutdown(). - One-liner setup via
TestKit.create().
For complex test setups (custom logger, multiple systems, specific
extensions), raw ActorSystem.create(...) may read more clearly.
Both produce the same actor behavior.
Where to next
Section titled “Where to next”- TestKit — the convenience facade around ActorSystem for tests.
- TestProbe — the fake actor for assertions.
- ManualScheduler — virtual-time scheduler for deterministic timer tests.
- MultiNodeSpec — multi-node cluster tests in one process.
- ParallelMultiNodeSpec — multi-node cluster tests in separate processes.