# Testing rooms

> Patterns for testing rooms with @irt/testing, from ticks and clocks to hibernation, crashes, reconnects, ownership, calls and migrations, and running them in CI.

`@irt/testing` runs a room in process, with the real `@irt/client` as
its clients and a clock that moves only when the test moves it. Tests
need no network, no `irt dev` and no account, so they run the same on a
laptop and in CI. This guide shows how to test what rooms usually get
wrong; every call is in the [@irt/testing reference](/docs/reference/testing).

```sh
npx irt test            # vitest run, in the directory of irt.config.ts
npx irt test src/room   # arguments go to vitest
```

## The shape of a test

```ts
// counter.ts
import { defineRoom, defineSchema, entity } from "@irt/room";

export const Count = entity("count", { n: "u32" });
export const Mine = entity("mine", { clicks: "u32" });

export default defineRoom({
  schema: defineSchema({ state: Count, private: Mine }),
  tickRate: 20,
  onMessage(room, conn, message) {
    if (message !== "click") return;
    room.state.n += 1;
    room.private(conn).clicks += 1;
  },
});
```

```ts
// counter.test.ts
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";
import room from "./counter.js";

test("every player sees the total and only their own clicks", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  const bob = await t.connect({ player: "bob" });

  alice.send("click");
  alice.send("click");
  bob.send("click");
  await t.host.tick();

  expect(bob.client.state.n).toBe(3);
  expect(alice.private.clicks).toBe(2);
  expect(bob.private.clicks).toBe(1);
  expect(t.live.state.n).toBe(3);
});
```

- `testRoom(def)` is one room, `"room"`, in project `"test"`.
  `createTestHost(def)` holds several rooms of one build in one room host.
- `connect({ player })` signs a token for that player ID; without
  `player` the client is a guest. `meta` sets the token's `meta` claim.
- Changes reach clients at the end of a tick, so `await t.host.tick()`
  before asserting on what a client sees.
- `t.live` is the running room: read it, or write its entities to set up a
  case, from the test.

## Time

| Call | Moves the clock | Use it to |
|---|---|---|
| `t.host.tick(n)` | `n / tickRate` seconds; every running room runs exactly `n` ticks | Step the simulation |
| `t.host.advance(ms)` | `ms`, running every timer that falls due | Cross timeouts: idle hibernation after 30 s, call timeouts, reconnect backoff |
| `t.host.clock.jump(ms)` | `ms`, running nothing until the next `tick` or `advance` | Simulate a blocked event loop |

Room code's own `setTimeout` and `Date.now()` use real time, which the
test doesn't move. Time things in ticks instead: store
`endsAt = room.tick + seconds * tickRate` in room state and compare in
`onTick`. For wall-clock time, use `room.now()`, which reads the test
clock: each host's clock starts at the real time when it's created, or
at `createTestHost(room, { startAt })` / `testRoom(room, { startAt })`,
milliseconds since the epoch, for a test that needs a known date. Two
hosts made a while apart have clocks that far apart, so room state moved
from one to the other wakes with that `elapsed`; give the second a
`startAt` past the first's `clock.wall()` to set the gap.

```ts
// catch-up.test.ts
import { defineRoom } from "@irt/room";
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";

test("a room that stalls runs at most 4 ticks to catch up", async () => {
  const t = testRoom(defineRoom({ tickRate: 20 }));
  await t.wake();
  const before = t.live.tick;
  t.host.clock.jump(1000);
  await t.host.advance(0);
  expect(t.live.tick - before).toBe(4);
});
```

## Hibernation and crashes

The three ways a room loses its process behave differently, and a room
should be tested through each:

| Call | Runs `onLeave` | Clients | Room starts again with |
|---|---|---|---|
| `t.hibernate()` | yes, for every connection | closed with 4011; they don't reconnect | `onWake`, when the next client connects or on `t.wake()` |
| `t.host.crash()` | no | reconnect by themselves | `onWake`, then `onJoin` for each client that reconnects |
| `client.drop()` | yes, for that connection | that client reconnects with backoff | nothing; the room keeps running |

```ts
// avatars.ts
import { defineRoom, defineSchema, entity } from "@irt/room";

export const Avatar = entity("avatar", { owner: "string", x: "f32" });
export const Wins = entity("wins", { wins: "u16" });

export default defineRoom({
  schema: defineSchema({ private: Wins, entities: [Avatar] }),
  onJoin(room, conn) {
    const existing = [...room.entities.all(Avatar)].find((a) => a.owner === conn.player);
    conn.data.avatar = existing ?? room.entities.create(Avatar, { owner: conn.player });
  },
  onMessage(room, conn, message) {
    if (message === "win") room.private(conn).wins += 1;
  },
  onPlayerLeave(room, player) {
    for (const e of room.entities.all(Avatar)) if (e.owner === player) room.entities.delete(e);
  },
});
```

```ts
// avatars.test.ts
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";
import room, { Avatar } from "./avatars.js";

test("a crash keeps one avatar per player", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  await t.host.crash();
  await alice.reconnected();
  expect(alice.view(Avatar)).toHaveLength(1);
  expect(alice.events).toContain("restored");
});

test("wins outlast hibernation and the avatar doesn't", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  alice.send("win");
  await t.host.tick();
  await t.hibernate();
  expect(alice.closedWith).toBe(4011);
  expect(t.stored().entities).toEqual([]);

  const back = await t.connect({ player: "alice" });
  expect(back.private.wins).toBe(1);
  expect(back.view(Avatar)).toHaveLength(1);
});

test("a dropped connection keeps the avatar, and leaving for good removes it", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  const bob = await t.connect({ player: "bob" });
  alice.drop();
  await alice.reconnected();
  await t.host.tick();
  expect(bob.view(Avatar).map((a) => a.owner).sort()).toEqual(["alice", "bob"]);
  await alice.leave();
  // onPlayerLeave runs once the presence grace, 10 seconds, has passed.
  await t.host.advance(10_000);
  await t.host.tick();
  expect(bob.view(Avatar).map((a) => a.owner)).toEqual(["bob"]);
});
```

- `t.stored()` is the room state a wake would start from, as `RoomData`.
  Assert on it to check what's saved without waking the room.
- `client.events` records `"waking"`, `"disconnected 1006"`, `"restored"`
  and `"closed 4011"` in order; `closedWith` is the final close code.
- `reconnected()` waits for the reconnect after the client's last drop or
  crash, and resolves at once if it already happened. After a crash every
  client reconnects while the clock moves for the first, so await each in
  turn: `await bob.reconnected(); await alice.reconnected();`.
- `t.host.advance(31_000)` after the last client leaves hibernates the
  room by its idle timeout, as in production.

## Calls

`client.call` is the real client's. The room runs a call's handler as
it arrives, between ticks. A call that changed room state replies only
after the next tick has saved the change, so start it, tick, then await
it; awaited before a tick, it never resolves, since the test clock
doesn't move by itself. A call that changed nothing, or that failed,
answers at once:

```ts
// shop.test.ts
import { defineCalls, defineRoom, defineSchema, entity } from "@irt/room";
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";

const Purse = entity("purse", { coins: "u32" });
const room = defineRoom({
  schema: defineSchema({ private: Purse, calls: defineCalls<{ buy(args: { price: number }): { left: number } }>() }),
  onJoin(room, conn) {
    room.private(conn).coins = 10;
  },
  calls: {
    buy(room, conn, { price }) {
      const purse = room.private(conn);
      if (!Number.isInteger(price) || price < 0) throw new Error("price must be a whole number");
      if (purse.coins < price) throw new Error("not enough coins");
      purse.coins -= price;
      return { left: purse.coins };
    },
  },
});

test("a call resolves to the reply or rejects with the handler's error", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  const bought = alice.client.call("buy", { price: 4 });
  await expect(alice.client.call("buy", { price: 40 })).rejects.toThrow("call buy failed in the room: not enough coins");
  await t.host.tick();
  await expect(bought).resolves.toEqual({ left: 6 });
  expect(alice.private.coins).toBe(6);
});

test("a call in flight fails when the connection drops", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  const pending = alice.client.call("buy", { price: 1 });
  alice.drop();
  await expect(pending).rejects.toThrow(/it may or may not have run/);
});
```

## Ownership and corrections

`client.mine(kind)` gives the entities a test client's player owns, with
writable fields to set. `expectCorrected` asserts the room overrode a
write:

```ts
// cursor.test.ts
import { defineRoom, defineSchema, entity } from "@irt/room";
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";

const Cursor = entity("cursor", { x: "f32" }, { writable: ["x"] });
const room = defineRoom({
  schema: defineSchema({ entities: [Cursor] }),
  onJoin(room, conn) {
    if ([...room.entities.ownedBy(conn.player, Cursor)].length === 0) room.entities.create(Cursor, {}, { owner: conn.player });
  },
  onWrite(_room, _conn, _entity, next) {
    return { ...next, x: Math.min(Math.max(next.x as number, 0), 100) };
  },
});

test("writes in bounds reach others, and the room clamps the rest", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  const bob = await t.connect({ player: "bob" });
  const [cursor] = alice.mine(Cursor);

  cursor!.x = 40;
  await t.host.tick(2);
  expect(bob.view(Cursor).find((c) => c.id === cursor!.id)).toMatchObject({ x: 40 });

  cursor!.x = 500;
  await t.host.tick(2);
  alice.expectCorrected(cursor!.id, "x");
  expect(cursor!.x).toBe(100);
});
```

## Several rooms

```ts
// isolation.test.ts
import { defineRoom, defineSchema, entity } from "@irt/room";
import { createTestHost, RoomStoppedError } from "@irt/testing";
import { expect, test } from "vitest";

const Score = entity("score", { n: "u32" });
const room = defineRoom({
  schema: defineSchema({ state: Score }),
  onMessage(room, _conn, message) {
    if (message === "boom") room.state.n = 999;
  },
  onTick(room) {
    if (room.state.n === 999) throw new Error("boom");
  },
});

test("a room that throws stops alone", async () => {
  const host = createTestHost(room);
  const a = await host.room("a").connect({ player: "alice" });
  const b = await host.room("b").connect({ player: "bob" });
  a.send("boom");
  await expect(host.tick()).rejects.toThrow(RoomStoppedError);
  expect(a.closedWith).toBe(4007);
  await host.tick();
  expect(b.client.status).toBe("open");
});
```

`tick` and `advance` throw `RoomStoppedError` when a room stopped, with
its tick and the stack. A test that doesn't expect it fails there, which
is usually what you want.

## Migrations

Test a migration by making room state with the old build and waking it
with the new one. Both hosts' storage is the same in-memory format, so
copy it across:

```ts
// migration.test.ts
import { defineRoom, defineSchema, entity } from "@irt/room";
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";

const PlayerV1 = entity("player", { owner: "string", hp: "u8" });
const v1 = defineRoom({
  schema: defineSchema({ entities: [PlayerV1] }),
  onCreate(room) {
    room.entities.create(PlayerV1, { owner: "alice", hp: 7 });
  },
});

const PlayerV2 = entity("player", { owner: "string", health: "u16" });
const v2 = defineRoom({
  schema: defineSchema({ entities: [PlayerV2] }),
  version: 2,
  migrations: {
    2: (data) => ({
      ...data,
      entities: data.entities.map((e) => (e.kind === "player" ? { ...e, values: { owner: e.values.owner, health: e.values.hp } } : e)),
    }),
  },
});

test("version 2 wakes rooms saved by version 1", async () => {
  const before = testRoom(v1);
  await before.wake();
  await before.hibernate();
  const { base, log } = before.host.storage.load("test", "room");

  const after = testRoom(v2);
  after.host.storage.writeBase("test", "room", base);
  for (const batch of log) after.host.storage.append("test", "room", batch);
  await after.wake();
  expect([...after.live.entities.all(PlayerV2)].map((p) => p.health)).toEqual([7]);
  expect(after.stored().version).toBe(2);
});

test("without the migration the room stops and says why", async () => {
  const before = testRoom(v1);
  await before.wake();
  await before.hibernate();
  const after = testRoom({ ...v2, migrations: {} });
  after.host.storage.writeBase("test", "room", before.host.storage.load("test", "room").base);
  await expect(after.wake()).rejects.toThrow(/no migration to version 2/);
});
```

Keep a saved copy of real room state from each released version in the
repository, as a file written from `storage.load(...).base`, and wake it
with the current build in a test, so a migration is tested against what
rooms really hold.

## Prediction and interest

- Prediction: `client.predict(stepper, { find, sample })` samples once per
  room tick as the test moves the clock. Assert on `settled`, not on
  `pending`; see [Testing it](/docs/guides/prediction#testing-it).
- Interest: `expectSees(id)` and `expectNotSees(id)` fail with what the
  client sees instead; see the [reference](/docs/reference/testing#testclient).
- Wire size: `client.lastUpdate` has the last state update's parts and
  bytes, to keep a game's bandwidth in check.

## Without a backend

The host has `createRoom`, `joinByCode` and `listPublic`, with the
project's settings applied; pass settings with
`testRoom(def, { settings })`. See
[Games without a backend](/docs/no-backend#testing).

## In CI

Tests need Node 24 or later and nothing else:

```yaml
# .github/workflows/test.yml
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
      - run: npm ci
      - run: npx irt test
```

`irt test` exits non-zero when a test fails. To deploy from CI after the
tests pass, see [Deploying from CI](/docs/builds#deploying-from-ci).

## Common mistakes

- **Asserting before a tick.** State updates go out at the end of a tick.
- **Starting a call that fails and awaiting it later.** The rejection
  arrives as soon as the room answers, and Vitest reports it as
  unhandled if nothing awaits it yet. Await calls where you make them.
- **Using `setTimeout` to wait.** Test time moves only with `tick` and
  `advance`; real waiting changes nothing.
- **Testing only the happy path.** Crash, drop and hibernate each run
  different hooks. A room that passes one can fail another.
- **Reconnecting a client after `hibernate()`.** It's closed with 4011
  and doesn't come back; connect a new one.
- **Sharing a host between tests.** Make one per test, so a stopped room
  or leftover client can't leak into the next.
