This guide covers the test setup, patterns, and conventions used in the Poker Engine codebase.
Test Stack
| Tool | Purpose |
|---|---|
| Vitest | Test runner, assertions |
| fast-check | Property-based testing |
| supertest | HTTP integration tests |
| vi.mock | Module mocking |
Running Tests
# Run all backend tests once
npm test
# Run tests in watch mode
npm run test:watch
# Run specific file
npm test -- src/game/hand-evaluator/evaluator.test.ts
# Run tests matching a name pattern
npm test -- -t "should detect royal flush"
# Generate coverage report
npm run test:coverage
# Run the high-risk concurrency and scale tests (excluded from npm test)
npm run test:release
# Run the frontend tests
npm run test:run --prefix client
npm run ci:local runs the whole PR gate locally against a disposable Postgres container.
Test File Locations
Tests live next to the source files they test:
src/game/hand-evaluator/evaluator.ts
src/game/hand-evaluator/evaluator.test.ts ← unit test
src/game/betting/pot.ts
src/game/betting/pot.test.ts ← unit test
src/game/betting/pot.property.test.ts ← property-based test
src/api/routes/auth.routes.ts
src/api/routes/auth.routes.test.ts ← route test (mocked services)
Vitest picks up src/**/*.test.ts, tests/**/*.test.ts and scripts/**/*.test.ts. A few locations are worth knowing:
tests/— cross-package tests, such as the auth lifecycle and mobile auth contract testssrc/tests/integration/— tests that use a real database, such asdecimal-precision.test.tssrc/game/integration/— game-flow testsclient/src/**/*.test.{ts,tsx}— frontend tests, run by the client’s ownvitest.config.ts(jsdom, setup fileclient/src/test/setup.ts)
Unit Tests
Unit tests use Vitest with describe/it/expect:
// src/game/hand-evaluator/evaluator.test.ts
import { describe, it, expect } from 'vitest';
import { evaluate5CardHand, evaluate7CardHand } from './evaluator.js';
import { HandRank, compareHands } from './hand-rank.js';
describe('evaluate5CardHand', () => {
describe('Royal Flush', () => {
it('should detect royal flush', () => {
const result = evaluate5CardHand(['AS', 'KS', 'QS', 'JS', 'TS']);
expect(result.rank).toBe(HandRank.ROYAL_FLUSH);
expect(result.name).toBe('Royal Flush');
});
it('should detect royal flush in any suit', () => {
const result = evaluate5CardHand(['AH', 'KH', 'QH', 'JH', 'TH']);
expect(result.rank).toBe(HandRank.ROYAL_FLUSH);
});
});
describe('Straight Flush', () => {
it('should detect wheel straight flush (A-2-3-4-5)', () => {
const result = evaluate5CardHand(['AS', '2S', '3S', '4S', '5S']);
expect(result.rank).toBe(HandRank.STRAIGHT_FLUSH);
expect(result.values[0]).toBe(5); // 5-high
});
});
describe('compareHands', () => {
it('should rank flush higher than straight', () => {
const flush = evaluate5CardHand(['AH', 'JH', '8H', '4H', '2H']);
const straight = evaluate5CardHand(['TS', '9H', '8D', '7C', '6S']);
expect(compareHands(flush, straight)).toBeGreaterThan(0);
});
});
});
Route Tests
Route tests mount the router on a small Express app and call it with supertest. Services are mocked, so no database is needed:
// src/api/routes/auth.routes.test.ts (simplified)
import { describe, it, expect, vi } from 'vitest';
import express from 'express';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import { authRoutes } from './auth.routes.js';
import { loginUser } from '../../services/auth.service.js';
vi.mock('../../services/auth.service.js', () => ({
loginUser: vi.fn(),
// ...every other export the route uses
}));
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/auth', authRoutes);
describe('POST /auth/login', () => {
it('should reject invalid credentials', async () => {
vi.mocked(loginUser).mockRejectedValue(new Error('Invalid credentials'));
const res = await request(app)
.post('/auth/login')
.send({ email: 'a@test.com', password: 'wrong' });
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
Integration Tests
Tests that exercise a real database, such as src/tests/integration/decimal-precision.test.ts and the high-risk concurrency tests listed in vitest.release.config.ts, need DATABASE_URL pointing at a disposable database with migrations applied. Clean up the data they create, and never run them against a database you want to keep.
Property-Based Testing
Source: src/game/betting/pot.property.test.ts, src/game/betting/rake.property.test.ts
Property-based tests use fast-check to generate random inputs and verify invariants:
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import { Pot } from './pot.js';
describe('Pot Property-Based Tests', () => {
// Define arbitraries (data generators)
const playerId = fc.stringMatching(/^[a-z][a-z0-9]{0,9}$/);
const contributionAmount = fc.integer({ min: 1, max: 1_000_000 });
it('pot total always equals sum of contributions', () => {
fc.assert(
fc.property(
fc.array(
fc.record({
playerId,
amount: contributionAmount,
isAllIn: fc.boolean(),
}),
{ minLength: 1, maxLength: 50 }
),
(contributions) => {
const pot = new Pot();
let expectedTotal = 0;
for (const contrib of contributions) {
pot.addContribution(contrib.playerId, contrib.amount, contrib.isAllIn);
expectedTotal += contrib.amount;
}
expect(pot.total).toBe(expectedTotal);
}
),
{ numRuns: 100 }
);
});
it('pot distribution never exceeds pot total', () => {
fc.assert(
fc.property(/* ... */, (setup) => {
const pot = buildPot(setup); // your own helper
// winnersByPot: Map<number, string[]>
const distributed = pot.distribute(setup.winnersByPot);
const totalDistributed = distributed.reduce((sum, d) => sum + d.amount, 0);
expect(totalDistributed).toBeLessThanOrEqual(pot.total);
})
);
});
});
Properties to test:
- Invariants — things that must always be true (total equals sum of contributions)
- Round-trips — encode then decode returns original value
- Commutativity — order of operations doesn’t matter when it shouldn’t
- Idempotency — doing the same thing twice has the same result
Mocking Patterns
Mocking Prisma
For unit tests that shouldn’t hit the database, mock the Prisma client:
import { vi, beforeEach } from 'vitest';
import { prisma } from '../../config/database.js';
vi.mock('../../config/database.js', () => ({
prisma: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}));
describe('my service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return user by id', async () => {
const mockUser = { id: '123', username: 'alice', email: 'alice@test.com' };
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any);
const result = await myService.getById('123'); // the code under test
expect(result).toEqual(mockUser);
});
});
Mocking Services
vi.mock('../../services/auth.service.js', () => ({
findUserById: vi.fn(),
loginUser: vi.fn(),
}));
Mocking Time
import { vi } from 'vitest';
// Fake timers for testing timeouts/delays
vi.useFakeTimers();
// Advance time
vi.advanceTimersByTime(5000);
// Restore real timers
vi.useRealTimers();
Test Utilities
There is no global test helper package. Keep shared helpers alongside the tests that use them.
WebSocket Testing
For testing WebSocket events, use socket.io-client in tests:
import { io as ioClient } from 'socket.io-client';
describe('WebSocket game events', () => {
let clientSocket: ReturnType<typeof ioClient>;
beforeAll(async () => {
clientSocket = ioClient('http://localhost:3000', {
auth: { token: testToken },
});
await new Promise<void>((resolve) => clientSocket.once('connect', () => resolve()));
});
afterAll(() => {
clientSocket.close();
});
it('should receive table_state after joining', async () => {
const state = new Promise<{ id: string }>((resolve) =>
clientSocket.once('table_state', resolve)
);
clientSocket.emit('join_table', { tableId, seatIndex: 0, buyIn: 100 });
expect(await state).toHaveProperty('id', tableId);
});
});
Test Environment
Importing src/config/env.ts validates environment variables, so even mocked tests need the required variables set. scripts/ci/env.test.sh and the env: block in .github/workflows/test.yml show a working test set. In CI, DATABASE_URL points at a disposable PostgreSQL service container.
vitest.config.ts configures the backend tests, with no global setup file:
// vitest.config.ts (excerpt)
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts', 'tests/**/*.test.ts', 'scripts/**/*.test.ts'],
exclude: [/* e2e, scale and concurrency tests */],
testTimeout: 20000,
hookTimeout: 20000,
},
});
vitest.release.config.ts runs the excluded high-risk concurrency and scale tests without file parallelism and with 30-second timeouts (npm run test:release).
Code Coverage
npm run test:coverage
Coverage uses the v8 provider with text, JSON and HTML reporters. No minimum thresholds are configured.
Testing Conventions
- One concept per test — each
it()block tests one behaviour - Descriptive names — "should reject duplicate email", not "test email"
- Arrange-Act-Assert — set up, perform action, check result
- Clean up after integration tests — delete test data in
afterAll/afterEach - Don’t test implementation details — test behaviour, not internal calls
- Property tests for math — use fast-check for any arithmetic (pot calculations, rake, payouts)
Back to Developer Guide