Skip to content

TestKit

TestKit is a thin convenience wrapper around ActorSystem for tests. It gives you a ready-to-use system with sensible test defaults — quiet logger, easy probe creation, deterministic shutdown.

import { TestKit } from 'actor-ts/testkit';
const tk = TestKit.create('my-spec');
const probe = tk.createTestProbe();
const ref = tk.system.actorOf(Props.create(() => new Worker(probe)));
ref.tell({ kind: 'do' });
await probe.expectMsg('done');
await tk.shutdown();

Three things the kit does for you:

  • Builds the ActorSystem with a NoopLogger by default — no console spam during test runs.
  • Exposes createTestProbe() so you don’t have to thread the system into every probe constructor.
  • Owns shutdown() — call once at test end; safely tears down the system, scheduler, and dispatcher.
class TestKit {
static create(name?: string, opts?: TestKitOptions): TestKit;
static withManualScheduler(name?, opts?): { kit: TestKit; scheduler: ManualScheduler };
readonly system: ActorSystem;
createTestProbe(opts?: TestProbeOptions): TestProbe;
within<T>(durationMs: number, fn: () => Promise<T>): Promise<T>;
shutdown(): Promise<void>;
}
interface TestKitOptions extends ActorSystemSettings {
quiet?: boolean; // default true — use NoopLogger
}

Builds a kit with a fresh system. The name is the system name — useful when running parallel test suites that mustn’t share state. Defaults to 'test-kit'.

const tk = TestKit.create('my-spec', {
quiet: false, // turn logging on
logLevel: LogLevel.Debug,
config: { /* HOCON overrides */ },
});

With quiet: false you get console logs — useful when debugging a failing test. Otherwise, log calls are no-ops, which keeps your test output clean.

const probe = tk.createTestProbe({
defaultTimeoutMs: 5_000, // default wait for expect/receive
name: 'order-probe', // visible in logs
});
ref.tell({ kind: 'do', replyTo: probe });
await probe.expectMsg({ kind: 'done' });

Returns a TestProbe scoped to this kit’s system. See TestProbe for the full expectations API.

await tk.within(1_000, async () => {
ref.tell({ kind: 'do' });
await probe.expectMsg('done');
});

Run fn and assert it completed in under durationMs. Throws if it took longer. Useful for “this should be fast” SLAs inside tests, without depending on the test runner’s per-test timeout.

Note: this is a soft deadline — fn still runs to completion even if it overshoots. It just throws after the fact.

afterEach(async () => {
await tk.shutdown();
});

Terminates the underlying system. Always call this in a fixture teardown — without it, the test process leaks a live system that keeps the event loop alive. Some test runners (Bun, Vitest) detect this and fail the suite; others hang.

const { kit, scheduler } = TestKit.withManualScheduler('my-spec');
const ref = kit.system.actorOf(Props.create(() => new Heartbeat(probe)));
ref.tell({ kind: 'start' });
scheduler.advance(5_000); // jump 5 virtual seconds
await probe.expectMsg('tick');

Returns both the kit and its ManualScheduler so the test can advance virtual time. See ManualScheduler for the full virtual-clock semantics.

import { describe, it, beforeEach, afterEach } from 'bun:test';
describe('Counter', () => {
let tk: TestKit;
let probe: TestProbe;
beforeEach(() => {
tk = TestKit.create();
probe = tk.createTestProbe();
});
afterEach(async () => {
await tk.shutdown();
});
it('increments', async () => {
const ref = tk.system.actorOf(Props.create(() => new Counter()));
ref.tell({ kind: 'inc' });
ref.tell({ kind: 'get', replyTo: probe as ActorRef<number> });
await probe.expectMsg(1);
});
});

A fresh kit per test — no state leakage between tests, slightly slower than sharing one kit but worth it for isolation.

describe('PureBehaviorTests', () => {
let tk: TestKit;
beforeAll(() => { tk = TestKit.create(); });
afterAll(async () => { await tk.shutdown(); });
// each `it` uses tk.createTestProbe() for its own probe, but the
// underlying ActorSystem is shared.
});

Faster than per-test setup; only safe when tests don’t mutate shared actors.

The TestKit API reference covers the full surface.