Appearance
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.tsCross-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 goodGroup 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)
| When | Idiom |
|---|---|
| Pure handler logic, no bus | MockRuntime — exported from @open-matrix/core. |
| Single actor + caller, in-process | MatrixRuntime + InMemoryTransport. |
| Inspect exact wire envelopes | Stub transport (recorded published array). |
| Cross-actor or cross-process | @matrix/test-utils — createNatsHarness 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.tsA package's full test suite
bash
pnpm --filter @open-matrix/my-package testAffected packages only
bash
pnpm test:affectedThis 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
setTimeoutfor synchronization. Race-prone. Useactor.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
- Actor testing — the four idioms in depth.
- Contract tests — verifying
accepts/emitsagainst a spec. - In-memory transport — what unit tests run on.
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).