Skip to content

Unit tests

Unit tests in matrix-work-harness run under node:test with tsx (the TypeScript-native loader). They construct one actor (or a small graph) on top of InMemoryTransport, exercise its handlers, and assert on the resulting state and bus traffic. They do not spawn external processes.

File layout

A package's unit tests live in tests/ next to src/:

projects/matrix-3/packages/my-package/
├── src/
│   └── runtime/MyActor.ts
└── tests/
    └── my-actor.spec.ts

Cross-package tests (involving multiple packages, integration scenarios) live under projects/matrix-3/tests/unit/. Per-package tests are package-local.

The convention is <thing>.spec.ts. The runner discovers files matching that pattern.

A complete unit test

ts
// projects/matrix-3/packages/my-package/tests/echo.spec.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  MatrixActor,
  MatrixRuntime,
  InMemoryTransport,
  InMemoryBroker,
  RequestReply,
} from '@open-matrix/core';

class Echo extends MatrixActor {
  static accepts = {
    'echo.say': {
      description: 'Echo the input back.',
      schema: { msg: { type: 'string', description: 'The message' } },
      returns: { msg: { type: 'string', description: 'The echoed message' } },
    },
  };
  async onEchoSay(payload: { msg: string }) {
    return { msg: payload.msg };
  }
}

test('Echo echoes', async () => {
  const broker = new InMemoryBroker();
  const transport = new InMemoryTransport(broker, { name: 'unit' });
  const runtime = new MatrixRuntime({ transport });

  await runtime.create(Echo, 'echo');

  const result = await RequestReply.request<{ msg: string }>(
    transport, 'echo', 'echo.say', { msg: 'hi' }, { timeoutMs: 1000 },
  );
  assert.equal(result.msg, 'hi');

  await runtime.shutdown();
});

This is the canonical unit-test shape. await runtime.shutdown() matters — without it, the test process may hold subscriptions open and not exit.

Conventions

Use node:test, not jest/mocha

node:test is built into Node 20+. It pairs with tsx for TypeScript without a transpilation step. There is no jest configuration, no mocha runner, no chai. The whole tree uses node:test.

assert/strict for assertions

ts
import assert from 'node:assert/strict';

strict mode enables deep equality with strict equality (===) at every level. Avoid assert.equal (loose equality) — use assert.equal from assert/strict, which is the same as assert.strictEqual.

Naming tests

Tests describe what should happen, not what the function is called:

ts
test('Echo echoes', async () => { ... });           // good
test('onEchoSay returns msg', async () => { ... }); // less good

Group related cases under t.test/describe (node:test supports both). Don't nest more than two levels — readability goes downhill fast.

Cleanup

Always:

  • Await runtime.shutdown() for any runtime you constructed.
  • Await harness.teardown() for any test-utils harness.
  • Clear any timers your test set up.

Forgetting cleanup leaves Node hung waiting for subscriptions to close. The runner has a default timeout (projects/matrix-3/scripts/test.ts accepts --timeout) but a leaky test means the whole suite ends in a kill rather than a clean pass.

Idioms (recap from Actor testing)

WhenIdiom
Pure handler logic, no busMockRuntime — exported from @open-matrix/core.
Single actor + caller, in-processMatrixRuntime + InMemoryTransport.
Inspect exact wire envelopesStub transport (recorded published array).
Cross-actor or cross-process@matrix/test-utilscreateNatsHarness or createDaemonHarness.

Actor testing covers each idiom in depth with full examples.

Asserting events

Subscribing and asserting on $events:

ts
const events: Array<{ topic: string; payload: unknown }> = [];
transport.subscribe('echo.$events', (payload, meta) => {
  events.push({ topic: meta.topic, payload });
});

await RequestReply.request(transport, 'echo', 'echo.say', { msg: 'hi' });
assert.equal(events.length, 1);
assert.equal((events[0].payload as MxEnvelope).op, 'echo.said');

Or use MatrixRuntime.onEvent(mount, op, handler) — it filters by op for you.

Asserting state changes

ts
const states: unknown[] = [];
transport.subscribe('counter.$events', (payload) => {
  const env = payload as MxEnvelope;
  if (env.op === '$stateChanged') states.push(env.payload);
});

await runtime.create(Counter, 'counter');
await RequestReply.request(transport, 'counter', 'increment', {});
assert.equal((states.at(-1) as { state: { count: number } }).state.count, 1);

Running

A single test file

bash
cd projects/matrix-3
pnpm exec tsx --test tests/unit/core/MatrixActor.spec.ts

A package's full test suite

bash
pnpm --filter @open-matrix/my-package test

Affected packages only

bash
pnpm test:affected

This runs tests for any package whose code (or transitive dependencies) changed.

Timeouts

Default per-test timeout is set by scripts/test.ts (--timeout 60000 for core per package.json:327). Override per-test:

ts
test('slow case', { timeout: 10000 }, async () => { ... });

Anti-patterns to avoid

  • Spinning up a real NATS server for unit tests. That's an integration test. Use InMemoryTransport.
  • Relying on setTimeout for synchronization. Race-prone. Use actor.ready, runtime.waitForReady, or explicit subscription assertions.
  • Asserting on log output. Logs are observability, not contract. Assert on emitted events / returned values.
  • Sharing state between tests. Each test should construct its own runtime. The runner does not reset module state.
  • Putting credentials or secrets in tests. Even fake-looking ones — they end up in fixtures and leak. See Secrets references.

See also

Source: projects/matrix-3/tests/unit/core/MatrixActor.spec.ts:1-19 (minimal smoke test), projects/matrix-3/tests/unit/core/unit-actor-invoke.spec.ts:1-80 (richer pattern), projects/matrix-3/scripts/test.ts (runner).