Testing overview
Ce contenu n’est pas encore disponible dans votre langue.
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 { 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 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.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.
Time-deterministic — ManualScheduler
Section titled “Time-deterministic — ManualScheduler”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 secondsawait 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.
Distributed — MultiNodeSpec
Section titled “Distributed — MultiNodeSpec”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.
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.
Wait on state, not on elapsed time
Section titled “Wait on state, not on elapsed time”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 machineawait 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 caseawait 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 arrive | TestProbe.expectMessage |
| a timer or retry to fire | ManualScheduler |
| a cluster to converge | spec.awaitMembers / awaitLeader / awaitMemberStatus |
| several nodes to reach the same point | spec.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.
When a fixed sleep is still right
Section titled “When a fixed sleep is still right”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.
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, 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.
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.
