Room testing API
testRoom runs room logic with real client behavior and a controllable clock. Start with a room test.
The harness handle
testRoom(definition, options?) takes your room definition, the default export of defineRoom.
| option | default | what it does |
|---|---|---|
seed | 1 | seeds room.random() and the latency jitter and loss generator |
latency | none | { rttMs, jitterMs, loss } on the in-process link |
mode | the room’s own | overrides 'tick' or 'event' |
tickRate | the room’s own | changes what one t.tick() costs in fake milliseconds |
roomId | 'test-room' | the room code every client joins |
writeIntervalMs | 50 | the clients’ owned-write flush window |
failOnHandlerError | true | throw out of t.tick / t.run / t.until / t.join when a room handler throws |
The handle it returns:
| member | what it is |
|---|---|
await t.join(spec?) | one client |
await t.join(n, spec?) | n clients, in join order |
await t.tick(n?) | run n server ticks (default 1) |
await t.run(ms) | advance ms of fake time, firing ticks and timers in order |
await t.until(pred, opts?) | tick until pred() holds, or reject when it never does |
t.state | the authority state your handlers mutate |
t.clients | every client that joined |
t.trace | every frame that crossed the seam, both directions, with timestamps |
t.rejections | every RPC call that came back as an error reply |
t.handlerErrors | every handler throw the runtime caught, with the handler name and tick |
t.dropped | frames the loss model discarded |
t.now, t.tickCount, t.mode | fake-clock time, tick counter, effective mode |
t.predictionStats() | per-client corrections, misprediction magnitude, snaps, replayed writes |
t.bandwidth() | outbound bytes per client and the per-tick average |
t.checkVisibility() | collections a client can see that its role must not |
t.convergence() | per-client differences from the authority |
t.stop() | every client leaves and the room stops |
A throwing handler fails the test
The runtime guards your handlers: one bad onJoin must not take a live room down, so the throw is
caught, counted and logged, and the room carries on. Under test that is the wrong trade — the room
limps along and the failure surfaces later as a t.until timeout pointing at something unrelated.
So the harness raises it at the line that ran the tick that broke, with the handler name and the
message. Pass failOnHandlerError: false when a throwing handler is the subject of the test; t.handlerErrors records them either way.
t.core and t.host are escape hatches onto the real RoomCore and its host, for the cases
below where you want to drive the room directly.
t.until steps the clock by one tick interval per attempt in tick mode and by 1 ms in event mode,
where a tick has no duration. Pass { stepMs } to cover a long wait in fewer attempts, or use t.run(ms) when you know how much time you need. { maxTicks } defaults to 1000.
Join specs
join takes { role, name, latency, interpDelayMs, rpc }. The rpc field holds your
implementations of the schema’s client-direction RPCs and goes straight to joinRoom. Without it
the client answers “no client implementation” and the only path you can test is the failing one.
const seen: string[] = [];
const screen = await t.join({
role: 'host',
rpc: { showResults: ({ winner }: { winner: string }) => void seen.push(winner) },
}); Three names that differ from the browser SDK
A TestClient is a real client room with three deliberate renames.
on a TestClient | means |
|---|---|
client.id | the client id, the same value as room.me |
client.roomId | the room code, which is what room.id means everywhere else |
client.view | an alias for client.state |
client.id shadowing the room code makes two-client tests read the way you would say them out
loud (expect(t.state.cards.ownerOf('c1')).toBe(a.id)). It is the opposite of @irtio/client, so
be sure which one you are holding.
Matchers
Import @irtio/testing/matchers once per test file. It is a side-effect import that registers
five matchers with Vitest’s expect.
| matcher | asserts |
|---|---|
expect(t).toHaveNoVisibilityLeaks() | no client’s view or received frame carried a collection its role must not see, and no spatial id outside that client’s authoritative neighborhood |
expect(t).toHaveConverged() | every client’s view equals the authority projected to that client’s role, and its interpolated room.render too once its stream has been idle for interpDelayMs |
expect(t).toHaveRejected('start') | some call to that RPC came back as an error reply |
expect(t).toStayUnderBandwidth(512) | no client averaged more than that many outbound bytes per tick |
expect(t).toStayWithinPrediction({ maxMagnitude: 5, maxSnaps: 0 }) | no correction snapped a client’s prediction further than maxMagnitude numeric units, and no client outran its resim window more than maxSnaps times |
Every failure message ends with the last ten frames of the trace, which is usually enough to see what happened:
expected no visibility leaks, found 1:
c2 (player) saw dots [far] in its frame at tick 41 (viewer c2@(0,0), dots far@(2,0), radius 1)
trace (last 10):
1980ms tick 40 → c2 WRITE 24B
2000ms tick 41 ← c2 DELTA 61B All five are also exported from @irtio/testing as plain functions, so a script with no test
runner can call them. The main entry point does not import Vitest at all.
A test that asserts a role cannot see something should also prove the detector fires. t.pretendRole(clientId, role) makes checkVisibility judge that client as a different role from
now on without telling the room, which is the only way to write a failing case for your own
visibility rules. See Visibility for how the rules are declared.
Relay rooms
Rooms with no schema get their own entry point with the same shape and the same fake clock. testRelay gives you presence and raw messages, and nothing else, because that is all a relay
room has.
// irtio/relay.test.ts
import { expect, test } from 'vitest';
import { testRelay } from '@irtio/testing';
test('a broadcast reaches everyone but the sender', async () => {
const t = await testRelay();
const [a, b, c] = await t.join(3);
a.message('all', new Uint8Array([1, 2, 3]));
await t.until(() => b.received.length > 0 && c.received.length > 0);
expect(b.received[0]?.from).toBe(a.me);
expect([...(b.received[0]?.bytes ?? [])]).toEqual([1, 2, 3]);
expect(a.received).toHaveLength(0);
t.stop();
}); A TestRelayClient is a real joinRelay room plus drop() and received, a recording of
everything onMessage delivered, so you can assert without wiring a callback. A targeted message
(a.message(b.me, bytes)) reaches only its target. testRelay({ seed, latency, roomId, maxClients }) takes the same latency model as testRoom.