Testing
wrpc's own suite runs on node --test with no framework, and the patterns below are the ones it uses. They work the same in Vitest or Jest — the only wrpc-specific parts are how you boot a server on a free port and how you make sure it is closed.
The boot pattern
const { test } = require('node:test');
const assert = require('node:assert');
const { Server, WrpcClient, defineRouter, procedure } = require('@alexify/wrpc');
const bootServer = async (t, options = {}) => {
const server = new Server({
host: '127.0.0.1',
port: 0, // let the OS pick — parallel test files never collide
protocol: 'http',
logger: false, // quiet: a passing test should print nothing
...options,
});
await server.listen();
t.after(() => server.close()); // registered the moment it resolves
const { port } = server.address();
return { server, url: `ws://127.0.0.1:${port}${server.rpc.basePath}` };
};
const connectClient = async (t, url, options = {}) => {
const client = await WrpcClient.connect(url, { heartbeat: false, reconnect: false, ...options });
t.after(() => void client.close());
return client;
};Register teardown with t.after, never a trailing await close()
If an assertion fails before the closing line runs, the server is never closed. With the default engine that leaks a socket; with a standalone uws engine it leaks a native listen socket, and node --test hangs forever instead of reporting the failure. t.after runs whether the test passed or not.
Three details earn their place:
port: 0— the OS picks a free port, so test files run in parallel without a port registry. Read it back withserver.address(), which also works with a standalone engine whereserver.httpServerisnull.logger: false— silences the defaultconsolewriter. Pass a collecting logger instead when the test is aboutlogging.heartbeat: false, reconnect: falseon the client — a test that asserts on a disconnect should not race a reconnect, and a 30-second heartbeat timer keeps the process alive.
A first test
test('greeting/hello answers', async (t) => {
const router = defineRouter({
greeting: {
hello: procedure({ access: 'public', handler: async (context, { name }) => `Hello, ${name}` }),
},
});
const { url } = await bootServer(t, { router });
const client = await connectClient(t, url);
await client.load('greeting');
assert.equal(await client.api.greeting.hello({ name: 'World' }), 'Hello, World');
});Waiting for something asynchronous
Events, room broadcasts and subscription values arrive on their own schedule. Polling with a deadline beats a fixed setTimeout, which is either flaky or slow:
const timers = require('node:timers/promises');
const waitFor = async (predicate, message = 'condition never held') => {
for (let i = 0; i < 300; i++) { // ~1.5 s
if (await predicate()) return;
await timers.setTimeout(5);
}
assert.fail(message);
};
test('a room broadcast reaches the client', async (t) => {
const { server, url } = await bootServer(t, { router });
const client = await connectClient(t, url);
await client.load('chat');
const seen = [];
client.api.chat.on('message', (data) => seen.push(data));
await waitFor(() => server.rpc.clients.size === 1);
server.to('lobby').emit('chat/message', { text: 'hi' }); // after a join
await waitFor(() => seen.length === 1, 'no event arrived');
});Testing multi-instance behaviour without infrastructure
MemoryBackplane is the reference implementation of the backplane contract, and two RpcServers sharing one behave like two processes sharing a Redis — including cluster presence and fetchClients:
const { RpcServer } = require('@alexify/wrpc');
const { MemoryBackplane } = require('@alexify/wrpc/scaling');
const backplane = new MemoryBackplane();
const a = new RpcServer({ router, backplane, instanceId: 'a' });
const b = new RpcServer({ router, backplane, instanceId: 'b' });Delivery is deferred to a microtask exactly like a real broker's, so a test that asserts immediately after an emit() will see nothing — await a tick, or use waitFor.
Reach for a real Redis only when you are testing the adapter; wrpc's own Redis suite is skip-guarded on REDIS_URL and is not part of pnpm test for that reason.
Testing without a network at all
rpc.attachPort(port) attaches a MessagePort as a transport — the same seam the Service Worker client uses. A node:worker_threads MessageChannel gives you a full client/server exchange with no listener, no port and no sockets:
const { MessageChannel } = require('node:worker_threads');
const { port1, port2 } = new MessageChannel();
rpc.attachPort(port1);Testing your handlers directly
A procedure is a plain object with an invoke(context, args, hooks) method, so unit-testing the handler needs no server. But do it deliberately: calling the handler function directly skips access checks, validators, hooks, the queue and the timeout — everything the router adds. For anything where that policy is part of the behaviour, boot a server; it costs a millisecond.
Adapters and optional dependencies
If your suite covers a host adapter, make the framework optional so a machine without it skips rather than fails — the pattern wrpc's own adapter tests use for uWebSockets.js, whose native binary does not build everywhere:
const optional = (name) => {
try {
return require(name);
} catch {
return null;
}
};
const uws = optional('uWebSockets.js');
test('uws engine', { skip: uws ? false : 'uWebSockets.js not installed' }, async (t) => { /* … */ });Running wrpc's own tests
pnpm test # everything, recursive
node --test tests/smoke.test.js # one file — node --test takes files, not dirs
node --test --test-name-pattern="rooms"
pnpm test:coverage # c8 over src/
pnpm test:types # tsd against the .d.ts filesWhen a run hangs instead of failing
Redirect to a file and read it back with a kill guard — piping through tail/head buffers the output and shows you nothing. A hang almost always means a leaked listen socket; look for a test that closes outside t.after.