9. Tests and scripts
9. Tests and scripts
101 tests, ~2.5 seconds. That speed is the payoff of keeping src/game/ free
of Socket.IO, Express and Prisma.
| Suite | Count | Needs |
|---|---|---|
tests/unit/game.test.js | 38 | nothing |
tests/unit/lobbyManager.test.js | 20 | nothing |
tests/integration/gameFlow.test.js | 25 | real sockets + Postgres |
tests/integration/reconnect.test.js | 9 | real sockets |
tests/integration/collectRace.test.js | 5 | real sockets |
tests/integration/isolation.test.js | 4 | real sockets |
npm test # everything
npm run test:unit # pure logic — no server, no DB, no browser
npm run test:integration # real sockets + real Postgres, --runInBand
Jest with plain ESM
jest.config.js sets transform: {} — no Babel, no transform step at all.
The npm script is:
node --experimental-vm-modules node_modules/jest/bin/jest.js
rather than NODE_OPTIONS=... jest, because inline env-var syntax does not work in
npm scripts on Windows (cmd.exe). This form is cross-platform and avoids needing
cross-env.
Globals are not injected under ESM, so every test imports them explicitly:
import { describe, test, expect, beforeEach } from '@jest/globals';
Jest runs each test file in its own worker process, so the lobbyManager
singleton is naturally isolated per file. Integration tests additionally use
--runInBand because they open real ports and share one database.
Unit tests
No server, no database, no browser — direct function calls.
function makeGame({ collectibleCount = 3 } = {}) {
return createGame({ code: 'TEST01', collectibleCount, rng: () => 0.5, now: 1000 });
}
A fixed rng and a fixed now make every run identical: same spawn
positions, same timestamps, no flakiness.
game.test.js covers
- The contested collect — two players on one orb, exactly one scores, and
alice.score + bob.score === 1 - Rejections: out of range, exactly at the edge (accepted), unknown ids, already-collected, finished game
- Orb count clamping — 5,000 becomes 75, garbage becomes the floor
- Movement: proportional to elapsed time, diagonal not faster, oversized vectors capped, short vectors preserved, garbage ignored, clamped at all four walls
- Username uniqueness: case-insensitive, whitespace-insensitive, stays claimed while disconnected, released on removal and on grace expiry
- Disconnect/reconnect: score and slot survive, dropped players stop sliding, grace expiry drops them
- Leaderboard ordering and tie-breaks
- Wire payloads: full state includes orbs, the 20 Hz snapshot does not, positions are integers, and the reconnect token never appears in any broadcast
lobbyManager.test.js covers
Code generation (500 codes, zero collisions), case-insensitive lookup, capacity,
and all three quickPlay rules — including never seating a player in a lobby
whose players have all disconnected.
Also lobby isolation at the logic level: collecting in lobby A leaves lobby B
untouched, and both lobbies hand out p0 without interfering because a token from
one is worthless in the other.
Integration tests
tests/helpers/testServer.js boots a real server on a random free port
(listen(0)) and connects real socket.io-client clients.
export async function startTestServer() { ... } // { url, manager, io, broadcaster, connect, close }
export function once(socket, event, timeoutMs) // next event as a promise
export function emit(socket, event, payload) // emit-with-ack as a promise
export function joinGame(socket, payload) // ack + the 'joined' payload
export function placeOn(game, playerId, id) // teleport, via server memory
export function waitFor(check, opts) // poll until truthy
Because the test runs in the same process as the server, it can reach into
manager.games and place a player exactly on an orb — making distance a
controlled constant rather than the thing under test.
collectRace.test.js — the headline
Two clients, then a full lobby of ten, all firing collect for the same orb
via Promise.all:
expect(results.filter((r) => r.ok)).toHaveLength(1);
expect(results.filter((r) => r.reason === 'ALREADY_COLLECTED')).toHaveLength(9);
expect(totalScoreInLobby).toBe(1);
The race is proved twice: deterministically at the function level in the unit test, and through real sockets here.
isolation.test.js — the rooms proof
A client in lobby B records every event it receives with onAny() while lobby
A is made as loud as possible. Then:
expect(collected).toHaveLength(0);
expect(everything).not.toContain(lobbyA.id);
expect(everything).not.toContain('alice');
for (const { payload } of snapshots) expect(payload.players).toHaveLength(1);
Asserting on a recording of everything is stronger than asserting a specific event did not arrive — it catches leaks you did not think to look for.
reconnect.test.js
Score/position/rank survive a dropped socket; a stale or foreign token falls back
to a normal join rather than erroring; others see player:left with reason
DISCONNECTED and the player stays on the leaderboard as connected: false.
Grace expiry is tested by calling tickGame with a future clock — instant, rather
than waiting 30 real seconds.
One test taught a lesson worth keeping: both listeners must be attached before the disconnect, because the server emits
player:leftandleaderboardback to back in the same turn. Subscribing to the second one afterwards always misses it.
gameFlow.test.js
Join validation, capacity, username uniqueness across lobbies, the ghost-lobby
matchmaking rule, movement, garbage-input resistance, /health, HTTP 404 shape,
and the persistence path: finishing a game writes one Game, N
GameParticipants and the PlayerStat upserts — verified against the real
database with waitFor, since persistence is intentionally not awaited.
It also covers "Play again": a socket can join a new game immediately after its game ends, no stale room membership remains, and every player can replay — not just the winner.
Cleanup runs in afterAll, deleting the games and usernames the file created.
scripts/stress.js
Headless socket.io-client bots that behave like browsers: seek the nearest orb,
send input at 20 Hz, request a collect when close.
node scripts/stress.js --lobbies=50 --players=10 --duration=30
| Flag | Default | Meaning |
|---|---|---|
--lobbies | 50 | Lobbies to create |
--players | 10 | Bots per lobby |
--duration | 30 | Seconds |
--ramp | 8 | Milliseconds between lobby batches |
--url | localhost:3000 | Target |
--shard | — | Label for running several processes |
Lobby recycling
A lobby of 10 bots clears 40 orbs in seconds, so games end mid-run. Rather
than inflating the orb count to an unplayable density, finished lobbies are
recycled: bots leave and join a freshly created lobby over the same socket.
That keeps the target lobby count live for the whole run, hammers the join/leave paths continuously, and — importantly on Windows — opens no new TCP connections, so the client cannot exhaust its ephemeral port range.
recycleSlot is guarded by a recycling flag because all ten bots receive
game:over and only one new lobby should be created for them.
What it measures
Client side: connections, joins, recycles, unexpected disconnects, state
messages/sec, collect attempts won vs lost, and ack latency percentiles.
Server side: it polls /health for tick p50/p95, heap and RSS — so both sides
appear in one report and you can tell which is the bottleneck rather than
guessing.
The correctness line is the important one:
orbs in finished games 28640 -> all accounted for ✓
cross-lobby leakage none ✓
Points awarded always equals orbs consumed, never more.
Reading the results honestly
At ~1,000 sockets the load generator is often the bottleneck, not the server — one Node process parsing 18,000 messages/sec on the same machine as the server. Compare client-observed latency against the server's own tick timings before blaming the server. Run in stages (1 → 10 → 50 → 100 lobbies) so a failure tells you where it broke.
scripts/seed.js and scripts/clean-test-data.js
npm run db:seed writes 12 plausible finished games so the all-time
leaderboard is not empty on a fresh clone. It deliberately uses the same
one-transaction-per-game shape as production code, including the raw ON CONFLICT
upsert.
npm run db:clean deletes rows whose username starts with bot or e2e_. A
stress run plays hundreds of real games, and Postgres cannot tell those from
real players — they end up on the all-time leaderboard. Run this before demoing.