Zum Inhalt springen
Deutsch

Testing — Überblick

Actor-Systeme sind naiv schwer zu testen, weil zwei Dinge von außen schwer sind:

  • Asynchronität — jedes tell passiert 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:

WerkzeugLöst
TestKitEin einsatzbereites ActorSystem mit leisem Logging + Helper-Methoden.
TestProbeEin Fake-Actor, der Nachrichten für Assertions erfasst.
ManualSchedulerEin Virtual-Clock-Scheduler — Zeit schreitet nur fort, wenn du es sagst.
MultiNodeSpecMehrere Cluster-Nodes in einem Prozess für Cluster-Szenario-Tests hochfahren.
ParallelMultiNodeSpecNodes 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.

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, exakt 2 ist, 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.

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.

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 Sekunden
await 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.

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.

Drei Kategorien:

  1. Reines Verhalten — gib dem Actor eine Sequenz von Nachrichten, assertete auf die Probe. Die meisten Actor-Unit-Tests sehen so aus.
  2. 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.
  3. Verteiltes Verhalten — für Cluster-Features gibt dir MultiNodeSpec einen „real genug”-Cluster, um Sharding- Placement, Singleton-Failover usw. zu testen.

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 Maschine
await 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 Fall
await 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 NachrichtTestProbe.expectMessage
das Feuern eines Timers oder RetrysManualScheduler
die Konvergenz eines Clustersspec.awaitMembers / awaitLeader / awaitMemberStatus
mehrere Nodes am selben Punktspec.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.

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.

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.

  • 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.