Testing — Überblick
Actor-Systeme sind naiv schwer zu testen, weil zwei Dinge von außen schwer sind:
- Asynchronität — jedes
tellpassiert später; Assertions müssen warten. - Zeit — Receive-Timeouts, geplante Retries, Gossip-Runden hängen alle von der Uhr ab.
Das TestKit des Frameworks gibt dir Werkzeuge, um beides zu kontrollieren:
| Werkzeug | Löst |
|---|---|
TestKit | Ein einsatzbereites ActorSystem mit leisem Logging + Helper-Methoden. |
TestProbe | Ein Fake-Actor, der Nachrichten für Assertions erfasst. |
ManualScheduler | Ein Virtual-Clock-Scheduler — Zeit schreitet nur fort, wenn du es sagst. |
MultiNodeSpec | Mehrere Cluster-Nodes in einem Prozess für Cluster-Szenario-Tests hochfahren. |
ParallelMultiNodeSpec | Nodes in separaten Prozessen laufen lassen, wenn Isolation zählt. |
Die ersten drei sind für Unit- + Integrationstests eines einzelnen Actor-Systems; die letzten zwei für verteilte Szenarien.
Ein minimaler Test
Abschnitt betitelt „Ein minimaler 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('inkrementiert und antwortet', 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(); });});Zwei Dinge, die das TestKit hier erledigt:
TestKit.create(name)baut ein frisches ActorSystem mit leisem Logging — kein Konsolen-Spam während Test-Läufen.probe.expectMessage(2)assertet, dass die nächste Nachricht, die die Probe empfängt, exakt2ist, mit einem Default-Timeout von 3 Sekunden.
tk.shutdown() am Ende ist kritisch — ohne ihn hinterlässt der
Test ein lebendes System + laufenden Dispatcher, und der
Test-Prozess kann hängen.
Die drei Schichten
Abschnitt betitelt „Die drei Schichten“Unit — Actor isoliert
Abschnitt betitelt „Unit — Actor isoliert“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 ist mit probe als Callback-Ziel konstruiert — er
tellt die Probe anstelle dessen, was er in Produktion tellen
würde. Tests asserten auf das, was die Probe sieht.
Das ist die kanonische Form — siehe TestKit und TestProbe.
Zeit-deterministisch — ManualScheduler
Abschnitt betitelt „Zeit-deterministisch — 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); // virtuelle Zeit springt 5 Sekundenawait probe.expectMessage({ kind: 'fired' });
await kit.shutdown();Actor, die context.timers.startSingleTimer oder
system.scheduler.scheduleOnce nutzen, bekommen ihr Feuern vom
Manual-Scheduler getrieben — kein echtes setTimeout, keine
Flakiness.
Siehe ManualScheduler.
Verteilt — MultiNodeSpec
Abschnitt betitelt „Verteilt — 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); // warten, bis alle drei konvergieren
// Jede Rolle ist ein vollständiges ActorSystem — via systemFor(role) erreichen: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();Drei Actor-Systeme joinen einen Cluster innerhalb eines Test- Prozesses, mit dem In-Memory-Transport. Nützlich zum Verifizieren von Sharding-Verhalten, Cluster-Singleton- Failover, Distributed-Data-Merge-Semantik — alles ohne ein Docker-Compose-Setup.
Siehe MultiNodeSpec.
Was testen
Abschnitt betitelt „Was testen“Drei Kategorien:
- Reines Verhalten — gib dem Actor eine Sequenz von Nachrichten, assertete auf die Probe. Die meisten Actor-Unit-Tests sehen so aus.
- State + Persistenz — für PersistentActor teste den Recovery-Pfad explizit, indem du den Actor stoppst und neu spawnst. Assertete, dass der wiederhergestellte State mit dem übereinstimmt, was du aus der Event-Sequenz erwarten würdest.
- Verteiltes Verhalten — für Cluster-Features gibt dir MultiNodeSpec einen „real genug”-Cluster, um Sharding- Placement, Singleton-Failover usw. zu testen.
Auf Zustand warten, nicht auf verstrichene Zeit
Abschnitt betitelt „Auf Zustand warten, nicht auf verstrichene Zeit“Fast jeder flakige Actor-Test hat dieselbe Form: ein fester Sleep als Platzhalter für einen Hintergrundschritt, danach eine Assertion auf den Zustand, den dieser Schritt erzeugt.
// ✗ 100 ms ist die Latenz einer unbelasteten Maschineawait new Promise((resolve) => setTimeout(resolve, 100));expect(store.saved).toEqual(['a', 'b']);Die Zahl stammt aus einem Lauf, der grün war — sie kodiert also eine Maschine an einem Tag. Unter Last dauert der Schritt länger, und die Assertion liest einen Zustand, den das System noch gar nicht erreicht hat — gemeldet als falscher Wert statt als verspäteter. Die Zahl zu erhöhen versteckt den Flake und verlängert jeden Lauf, auch die überwältigende Mehrheit, der ein Bruchteil gereicht hätte.
Stattdessen den beobachtbaren Zustand pollen:
// ✓ kehrt zurück, sobald die Bedingung hält; das Timeout begrenzt nur den defekten Fallawait awaitCondition(() => store.saved.length === 2, { timeoutMs: 5_000, label: 'both events were written',});expect(store.saved).toEqual(['a', 'b']);Daraus folgen zwei Dinge:
- Das Timeout ist ein Fehlerbudget, keine erwartete Dauer. Ein grüner Test kehrt nach einem Poll-Intervall zurück, also setze es komfortabel über die schlimmste plausible Latenz unter Last. Es zu erreichen bedeutet dann, dass die Bedingung wirklich nie wahr wurde — das macht es zur Diagnose statt zum Münzwurf.
- Warte auf die stärkste Bedingung, die der Test beobachten kann — eine Probe-Antwort, ein Recovery-Callback, ein Spy-Array — nie auf einen Stellvertreter, den ein halb fertiger Schritt schon erfüllt. Ein Stellvertreter, der bereits bei der ersten Hälfte eines Schritts umschlägt, ist schlechter als der Sleep, den er ersetzt hat.
Greif zum passenden Werkzeug, bevor du selbst pollst:
| Statt zu schlafen auf… | nimm |
|---|---|
| das Eintreffen einer Nachricht | TestProbe.expectMessage |
| das Feuern eines Timers oder Retrys | ManualScheduler |
| die Konvergenz eines Clusters | spec.awaitMembers / awaitLeader / awaitMemberStatus |
| mehrere Nodes am selben Punkt | spec.enterBarrier |
Für alles, was die nicht abdecken, genügt ein eigenes awaitCondition von
einem Dutzend Zeilen — actor-ts hält eines in tests/util/AwaitCondition.ts.
Wann ein fester Sleep weiterhin richtig ist
Abschnitt betitelt „Wann ein fester Sleep weiterhin richtig ist“Warten ist korrekt, wenn die verstrichene Zeit selbst das Prüfobjekt ist oder wenn es überhaupt keinen Zustandsübergang gibt, auf den man warten könnte:
- Es darf nichts passieren.
expectNoMessage(150), oder „das Heilen einer Partition erweckt einen heruntergefahrenen Node nicht wieder” — man kann dem Falschen nur ein Zeitfenster geben, in dem es eintreten könnte. - Eine Dauer ist die Assertion. Ein Debounce-Fenster, das Ablaufen einer Lease-TTL, die Schwelle eines Failure Detectors, eine Gossip-Kadenz.
- Ein Szenario braucht eine bestimmte Verschränkung. „Crashe einen Node 20 ms nach dem Start eines Ask-Batches” legt den Crash mitten in den Flug; diese Verzögerung ist das Experiment.
- Das Warten ist ein Warm-up, auf das nichts assertet, und die folgende Operation bringt ihr eigenes Timeout mit, das einen langsamen Start abfängt.
Schreib in einem Kommentar dazu, welcher der Fälle es ist. Ein Sleep mit Begründung ist Dokumentation; ein Sleep ohne ist eine Wette.
Was nicht testen
Abschnitt betitelt „Was nicht testen“TestKit vs rohes ActorSystem in Tests
Abschnitt betitelt „TestKit vs rohes ActorSystem in Tests“Beides funktioniert. TestKit ist Convenience:
- Default ist
NoopLogger— kein Konsolen-Spam. - Bietet
createTestProbe(),within(ms, callback),shutdown(). - One-Liner-Setup via
TestKit.create().
Für komplexe Test-Setups (eigener Logger, mehrere Systeme,
spezifische Extensions) liest sich rohes ActorSystem.create(...)
manchmal klarer. Beides produziert dasselbe Actor-Verhalten.
Wo es weitergeht
Abschnitt betitelt „Wo es weitergeht“- TestKit — die Convenience- Fassade um ActorSystem für Tests.
- TestProbe — der Fake-Actor für Assertions.
- ManualScheduler — Virtual-Time-Scheduler für deterministische Timer-Tests.
- MultiNodeSpec — Multi-Node-Cluster-Tests in einem Prozess.
- ParallelMultiNodeSpec — Multi-Node-Cluster-Tests in separaten Prozessen.
