Testing

This guide covers the test setup, patterns, and conventions used in the Poker Engine codebase.

Test Stack

ToolPurpose
VitestTest runner, assertions
fast-checkProperty-based testing
supertestHTTP integration tests
vi.mockModule 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 tests
  • src/tests/integration/ — tests that use a real database, such as decimal-precision.test.ts
  • src/game/integration/ — game-flow tests
  • client/src/**/*.test.{ts,tsx} — frontend tests, run by the client’s own vitest.config.ts (jsdom, setup file client/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

Try the live demo Pricing

Have questions about Poker Engine? We reply fast.