Skip to main content

Testing Guide

Tiao uses three test frameworks across three layers:

LayerFrameworkConfig
Server unit testsNode.js built-in node:testserver/package.json ("test" script)
Client unit testsVitest + React Testing Libraryclient/vite.config.mts
E2E testsPlaywrightplaywright.config.ts

Running Tests

# Server unit tests (compiles TypeScript first, then runs)
npm --prefix server test

# Client unit tests
cd client && npx vitest run # single run
cd client && npx vitest # watch mode
cd client && npx vitest --ui # interactive browser UI

# E2E tests (requires both servers running)
npx playwright test # all specs
npx playwright test e2e/auth.spec.ts # single file
npx playwright test --headed # visible browser
npx playwright show-report # HTML report

For E2E tests, the Playwright config auto-starts both servers (npm run server and npm run client). If they're already running, it reuses them.

Test File Locations

Server Tests (server/tests/)

FileTestsWhat it covers
tiaoCore.test.ts8Core game rules: initial state, border rule, cluster rule, jump chains, undo, jump origins, game over
tiaoCoreEdgeCases.test.ts19Edge cases: occupied/OOB placement, own-piece jumps, diagonal jumps, multi-jump chains, border corners, cluster shapes, game-over at 10, pending jump blocks, utility functions
api.test.ts6REST endpoints: health check, guest auth, logout, game CRUD, matchmaking API, auth enforcement
authRoutes.test.ts8Auth routes: signup/login 503 behavior, guest creation, display name truncation, session management
gameService.test.ts9Service layer: room persistence, seat randomization, guest limits, game library, spectators, online status, matchmaking, rematch flow
gameServiceActions.test.ts10Game actions via service: place-piece, turn enforcement, spectator rejection, jump+confirm capture, undo, broadcasts, rematch guards
matchmakingEdgeCases.test.ts8Matchmaking: double-join, leave queue, matched state cleanup, guest limits, three-player queue
boardHarness.ts--Test utility (not a test file)

Client Tests (client/src/)

FileTestsWhat it covers
App.test.tsx1App component renders with router
lib/computer-ai.test.ts7AI strategy: placement selection, center bias, jump preference, game-over handling
lib/hooks/useLocalGame.test.tsx9Local game: turn alternation, piece selection, jump targets, color validation
lib/hooks/useGamesIndex.test.ts6Games list: initialization, conditional fetch, malformed responses, auth state changes
lib/hooks/useComputerGame.test.tsx5Computer game: human turn start, click blocking during AI turn, controls disabled state
lib/hooks/useMultiplayerGame.test.ts10Multiplayer: connect/disconnect, snapshot updates, optimistic updates, error handling, message sending
lib/hooks/useMatchmakingData.test.ts7Matchmaking hook: enter/cancel, polling, immediate match, null auth guard
lib/hooks/useSocialData.test.ts11Social: friend request CRUD, search, guest guards, auth state transitions
lib/hooks/useLobbySocket.test.ts6Lobby socket: connect/disconnect, message handling, auth guards

E2E Tests (e2e/)

FileWhat it covers
localTurns.spec.tsLocal game turn alternation and opponent piece blocking
localGameFull.spec.tsScore display, piece persistence, jump capture workflow
computerGame.spec.tsHuman vs AI: placement, AI response, turn enforcement
rematch.spec.tsFull multiplayer rematch accept flow
rematchDecline.spec.tsRematch request and decline flow
matchmaking.spec.tsTwo players queue and get matched
auth.spec.tsSignup, login, guest access
spectator.spec.tsThird player views game without joining
lobby.spec.tsLobby buttons, game creation, active game list

Server Test Harness

Route-Level Testing (api.test.ts, authRoutes.test.ts)

Server tests don't spin up an HTTP server. Instead, they import Express routers directly and invoke route handlers with mock req/res objects:

const response = await invokeRoute<AuthResponse>(gameAuthRoutes, {
method: "post",
path: "/guest",
body: { displayName: "Alice" },
});

assert.equal(response.status, 201);
assert.equal(response.body.player.displayName, "Alice");

The invokeRoute helper finds the matching route layer on the Express router, constructs a mock request with the given method/path/body/cookie, runs all middleware handlers in sequence, and returns { status, body, headers }.

Singleton Patching

The gameService singleton is monkey-patched in beforeEach with a fresh instance backed by InMemoryGameRoomStore:

beforeEach(async () => {
const service = new GameService(new InMemoryGameRoomStore(), () => 0);
singletonGameService.createGame = service.createGame.bind(service);
// ... same for all methods
});

afterEach(() => {
Object.assign(singletonGameService, originalMethods);
});

The resetPlayerSessionStoreForTests() function clears the in-memory session store between tests.

Board ASCII Harness (boardHarness.ts)

Create game states from ASCII diagrams for readable tests:

const state = stateFromDiagram(`
W . .
. B .
. . .
`, { origin: { x: 5, y: 5 }, turn: "white" });

// W = white, B = black, . = empty
// origin offsets the diagram onto the 19x19 board

Assert board regions the same way:

assertRegion(state, `
. . .
. B .
. . W
`, { origin: { x: 5, y: 5 } });

FakeSocket

A minimal WebSocket mock that captures sent messages:

class FakeSocket {
readyState = WebSocket.OPEN;
messages: string[] = [];
send(message: string) { this.messages.push(message); }
}

Client Test Harness

API Mocking with vi.mock

Every hook test mocks the API module to prevent real HTTP calls:

const mockEnterMatchmaking = vi.fn();

vi.mock("../api", () => ({
enterMatchmaking: (...args) => mockEnterMatchmaking(...args),
}));

Error toasts are also mocked to keep tests silent:

vi.mock("../errors", () => ({ toastError: vi.fn() }));

Hook Testing with renderHook

React hooks are tested using renderHook from @testing-library/react:

const { result } = renderHook(() => useMatchmakingData(mockAuth, onMatched));

await act(async () => {
await result.current.handleEnterMatchmaking();
});

expect(result.current.matchmaking.status).toBe("searching");

MockWebSocket

For WebSocket-dependent hooks (useMultiplayerGame, useLobbySocket), a mock WebSocket class is injected:

class MockWebSocket {
static instances: MockWebSocket[] = [];
// ... captures addEventListener calls, exposes simulateOpen/simulateMessage/simulateClose
}

vi.stubGlobal("WebSocket", MockWebSocket);

Fake Timers

For timer-dependent hooks (useComputerGame, useMatchmakingData):

vi.useFakeTimers();

// Advance time to trigger polling interval
await act(async () => {
vi.advanceTimersByTime(2100);
});

vi.useRealTimers();

E2E Test Patterns

Multi-Player Tests

Each player gets a separate browser context with its own cookies:

const aliceContext = await browser.newContext();
const bobContext = await browser.newContext();
const alicePage = await aliceContext.newPage();
const bobPage = await bobContext.newPage();

Board Cell Selection

Board cells have data-testid="cell-{x}-{y}" attributes:

function cell(page, x, y) {
return page.locator(`[data-testid="cell-${x}-${y}"]`);
}

await cell(page, 9, 9).click();
await expect(cell(page, 9, 9)).toHaveAttribute("data-piece", "white");

Force-Finishing Games

The test-finish dev endpoint skips a full game playthrough:

await page.evaluate(async (gameId) => {
await fetch(`/api/games/${gameId}/test-finish`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ winner: "white" }),
});
}, gameId);

Adding New Tests

Server test

  1. Create server/tests/yourTest.test.ts
  2. Use node:test and node:assert/strict
  3. Import from ../../shared/src for game types/functions
  4. Use InMemoryGameRoomStore and resetPlayerSessionStoreForTests() for isolation
  5. Run with npm --prefix server test

Client test

  1. Create client/src/lib/hooks/yourHook.test.ts (or .tsx if rendering components)
  2. Use Vitest globals (describe, it, expect, vi)
  3. Mock API calls with vi.mock("../api", ...)
  4. Use renderHook for hook tests, render for component tests
  5. Run with cd client && npx vitest run

E2E test

  1. Create e2e/yourFlow.spec.ts
  2. Use @playwright/test (test, expect)
  3. Use browser.newContext() for multi-player scenarios
  4. Run with npx playwright test e2e/yourFlow.spec.ts --headed