Skip to content
English

Testing overview

Actor systems are tricky to test naively because two things are hard from outside:

  • Asynchrony — every tell happens 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:

ToolSolves
TestKitA ready-to-use ActorSystem with quiet logging + helper methods.
TestProbeA fake actor that captures messages for assertions.
ManualSchedulerA virtual-clock scheduler — time advances only when you say so.
MultiNodeSpecSpin up multiple cluster nodes in one process for cluster-scenario tests.
ParallelMultiNodeSpecRun 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.

import { describe, it, expect } from 'bun:test';
import { match } from 'ts-pattern';
import { Actor, ActorRef } from 'actor-ts';
import { TestKit } from 'actor-ts/testkit';
type IncrementCommand = { kind: 'increment' };
type GetCommand = { kind: 'get'; replyTo: ActorRef<number> };
type Command = IncrementCommand | GetCommand;
class Counter extends Actor<Command> {
private count = 0;
override onReceive(command: Command): void {
match(command)
.with({ kind: 'increment' }, () => this.onIncrement())
.with({ kind: 'get' }, (c) => this.onGet(c))
.exhaustive();
}
private onIncrement(): void { this.count++; }
private onGet(command: GetCommand): void { command.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.spawnAnonymous(Counter);
counter.tell({ kind: 'increment' });
counter.tell({ kind: 'increment' });
counter.tell({ kind: 'get', replyTo: probe as ActorRef<number> });
await probe.expectMessage(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.expectMessage(2) asserts the next message the probe receives is exactly 2, 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.

const tk = TestKit.create();
const probe = tk.createTestProbe();
const ref = tk.system.spawnAnonymous(() => new MyActor(probe));
ref.tell({ kind: 'do' });
await probe.expectMessage('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.

const { kit, scheduler } = TestKit.withManualScheduler();
const probe = kit.createTestProbe();
const ref = kit.system.spawnAnonymous(() => new ScheduledThing(probe));
ref.tell({ kind: 'start' });
scheduler.advance(5_000); // virtual time jumps 5 seconds
await probe.expectMessage({ 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.

import { MultiNodeSpec, TestProbe } from 'actor-ts/testkit';
const spec = new MultiNodeSpec({ roles: ['frontend', 'worker-1', 'worker-2'] });
await spec.start();
await spec.awaitMembers('frontend', 3); // wait for all three to converge
// Each role is a full ActorSystem — reach it via systemFor(role):
const probe = new TestProbe(spec.systemFor('frontend'));
const remote = spec.systemFor('worker-1').spawnAnonymous(Worker);
remote.tell({ kind: 'do', replyTo: probe });
await probe.expectMessage({ kind: 'done' });
await spec.stop();

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.

Three categories:

  1. Pure behavior — give the actor a sequence of messages, assert on the probe. Most actor unit tests look like this.
  2. 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.
  3. Distributed behavior — for cluster features, MultiNodeSpec gives you a “real-enough” cluster to test sharding placement, singleton failover, etc.

Nearly every flaky actor test has one shape: a fixed sleep standing in for a background step, then an assertion on the state that step produces.

// ✗ 100 ms is the latency of an idle machine
await new Promise((resolve) => setTimeout(resolve, 100));
expect(store.saved).toEqual(['a', 'b']);

The number comes from a run that passed, so it encodes one machine on one day. Under load the step takes longer, and the assertion reads a state the system has not reached yet — reported as a wrong value rather than a late one. Raising it hides the flake and lengthens every run, including the overwhelming majority that would have been fine with a fraction of it.

Poll the observable instead:

// ✓ returns as soon as it holds; the timeout only bounds the broken case
await awaitCondition(() => store.saved.length === 2, {
timeoutMs: 5_000,
label: 'both events were written',
});
expect(store.saved).toEqual(['a', 'b']);

Two things follow from that:

  • The timeout is a failure budget, not an expected duration. A passing test returns after one poll interval, so set it comfortably above the worst plausible loaded latency. Reaching it then means the condition genuinely never became true, which makes it a diagnostic instead of a coin flip.
  • Wait on the strongest condition the test can observe — a probe reply, a recovery callback, a spy array — never a proxy a half-finished step already satisfies. A proxy that clears when the first half of a step runs is worse than the sleep it replaced.

Reach for the purpose-built tool before writing a poll:

Instead of sleeping for…Use
a message to arriveTestProbe.expectMessage
a timer or retry to fireManualScheduler
a cluster to convergespec.awaitMembers / awaitLeader / awaitMemberStatus
several nodes to reach the same pointspec.enterBarrier

For anything those do not cover, a dozen-line awaitCondition of your own does the job — actor-ts keeps one in tests/util/AwaitCondition.ts.

Sleeping is correct when the elapsed time is the thing under test, or when there is no state transition to wait for at all:

  • Nothing must happen. expectNoMessage(150), or “healing a partition does not resurrect a downed node” — you can only give the wrong thing a window in which to occur.
  • A duration is the assertion. A debounce window, a lease TTL lapsing, a failure detector’s threshold, a gossip cadence.
  • A scenario needs an interleaving. “Crash a node 20 ms into a batch of asks” puts the crash mid-flight; that delay is the experiment.
  • The wait is a warm-up nothing asserts on, and the operation after it carries its own timeout that absorbs a slow start.

Say which one it is in a comment. A sleep with a reason is documentation; a sleep without one is a bet.

Both work. TestKit is convenience:

  • Defaults to NoopLogger — no console spam.
  • Provides createTestProbe(), within(ms, callback), 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.