OpenFront uses Vitest for testing, ensuring game logic stability and catching regressions early.
Overview
OpenFront’s testing strategy focuses on:
Core Logic Deterministic simulation must be thoroughly tested
Game Mechanics All gameplay features need test coverage
Regression Prevention Tests prevent breaking existing functionality
Client Rendering UI components and rendering logic
Test Framework
OpenFront uses Vitest , a fast unit test framework with:
Native ESM support
TypeScript support out of the box
Jest-compatible API
Fast watch mode
Coverage reports via v8
Configuration
Vitest is configured in vite.config.ts:
export default defineConfig ({
test: {
globals: true ,
environment: 'jsdom' ,
setupFiles: './tests/setup.ts' ,
} ,
}) ;
Running Tests
Basic Commands
Run All Tests
Run with Coverage
Watch Mode (Vitest UI)
The npm test command runs both core tests and server tests: vitest run && vitest run tests/server
Test Scripts
Command Description npm testRun all tests once npm run test:coverageGenerate coverage report npm run perfRun performance benchmarks
Test Structure
Tests are organized in the /tests directory:
tests/
├── client/ # Client-specific tests
├── core/ # Core game logic tests
├── server/ # Server tests
├── economy/ # Economy system tests
├── nukes/ # Nuclear weapons tests
├── pathfinding/ # Pathfinding algorithm tests
├── perf/ # Performance benchmarks
├── util/ # Utility function tests
├── setup.ts # Test setup/configuration
└── *.test.ts # Root-level test files
Writing Tests
Basic Test Structure
import { describe , it , expect } from 'vitest' ;
import { calculateAttack } from '@/core/combat' ;
describe ( 'calculateAttack' , () => {
it ( 'should calculate damage correctly' , () => {
const result = calculateAttack ({
attackerTroops: 100 ,
defenderTroops: 50 ,
terrain: 'plain' ,
});
expect ( result . damage ). toBeGreaterThan ( 0 );
expect ( result . remainingTroops ). toBeLessThan ( 50 );
});
});
Testing Core Logic
All changes to /src/core MUST include tests. This is non-negotiable for game stability.
Example core test:
import { describe , it , expect } from 'vitest' ;
import { AttackExecution } from '@/core/executions/AttackExecution' ;
import { GameState } from '@/core/state/GameState' ;
describe ( 'AttackExecution' , () => {
it ( 'should reduce attacker troops' , () => {
const state = new GameState ({ seed: 123 });
const territory1 = state . territories [ 0 ];
const territory2 = state . territories [ 1 ];
territory1 . troops = 100 ;
territory2 . troops = 50 ;
const execution = new AttackExecution ({
from: territory1 . id ,
to: territory2 . id ,
troops: 30 ,
});
execution . execute ( state );
expect ( territory1 . troops ). toBeLessThan ( 100 );
});
it ( 'should be deterministic' , () => {
// Run same attack twice with same seed
const state1 = new GameState ({ seed: 456 });
const state2 = new GameState ({ seed: 456 });
const execution1 = new AttackExecution ({ /* ... */ });
const execution2 = new AttackExecution ({ /* ... */ });
execution1 . execute ( state1 );
execution2 . execute ( state2 );
// Results must be identical
expect ( state1 . territories ). toEqual ( state2 . territories );
});
});
Testing Determinism
Determinism is critical for OpenFront’s architecture:
Use Seeded Random
Always use seeded random number generators: import seedrandom from 'seedrandom' ;
const rng = seedrandom ( 'test-seed' );
const randomValue = rng ();
Test Identical Results
Run the same operation twice with the same seed: it ( 'produces identical results with same seed' , () => {
const result1 = simulateTick ({ seed: 123 });
const result2 = simulateTick ({ seed: 123 });
expect ( result1 ). toEqual ( result2 );
});
Test Different Seeds
Verify different seeds produce different results: it ( 'produces different results with different seeds' , () => {
const result1 = simulateTick ({ seed: 123 });
const result2 = simulateTick ({ seed: 456 });
expect ( result1 ). not . toEqual ( result2 );
});
Testing Client Components
For UI components using Lit:
tests/client/ui/Button.test.ts
import { describe , it , expect } from 'vitest' ;
import { fixture , html } from '@open-wc/testing' ;
import './Button' ;
describe ( 'Button Component' , () => {
it ( 'renders with text' , async () => {
const el = await fixture ( html `
<custom-button>Click Me</custom-button>
` );
expect ( el . textContent ). toBe ( 'Click Me' );
});
it ( 'emits click event' , async () => {
let clicked = false ;
const el = await fixture ( html `
<custom-button @click= ${ () => clicked = true } >
Click Me
</custom-button>
` );
el . click ();
expect ( clicked ). toBe ( true );
});
});
Testing Server Logic
Server tests are in /tests/server:
tests/server/Lobby.test.ts
import { describe , it , expect , beforeEach } from 'vitest' ;
import { LobbyManager } from '@/server/lobby/LobbyManager' ;
describe ( 'LobbyManager' , () => {
let lobby : LobbyManager ;
beforeEach (() => {
lobby = new LobbyManager ();
});
it ( 'creates new lobby' , () => {
const lobbyId = lobby . create ({
maxPlayers: 4 ,
map: 'europe' ,
});
expect ( lobbyId ). toBeDefined ();
expect ( lobby . get ( lobbyId ). maxPlayers ). toBe ( 4 );
});
it ( 'adds players to lobby' , () => {
const lobbyId = lobby . create ({ maxPlayers: 2 });
lobby . addPlayer ( lobbyId , 'player1' );
lobby . addPlayer ( lobbyId , 'player2' );
expect ( lobby . get ( lobbyId ). players . length ). toBe ( 2 );
});
});
Coverage Requirements
Core logic (/src/core) should aim for high test coverage (80%+ recommended).
Generate coverage reports:
This creates a coverage report in /coverage:
coverage/
├── index.html # HTML coverage report
├── lcov.info # LCOV format for CI tools
└── coverage.json # Raw coverage data
Coverage Metrics
Metric Target Description Statements >80% Individual statements executed Branches >75% Conditional branches covered Functions >80% Functions called in tests Lines >80% Lines of code executed
Focus on meaningful coverage over arbitrary percentages. A well-tested feature at 70% coverage is better than superficial tests at 90%.
Testing Best Practices
1. Test Behavior, Not Implementation
it ( 'allows player to attack adjacent territory' , () => {
const result = game . attack ( territoryA , territoryB );
expect ( result . success ). toBe ( true );
});
2. Use Descriptive Test Names
it ( 'prevents attack when territories are not adjacent' )
it ( 'calculates correct alliance donation percentage' )
it ( 'handles disconnected player gracefully' )
3. Test Edge Cases
describe ( 'Territory Capture' , () => {
it ( 'handles zero troops' , () => { /* ... */ });
it ( 'handles maximum troops' , () => { /* ... */ });
it ( 'handles negative troop input' , () => { /* ... */ });
it ( 'handles non-existent territory' , () => { /* ... */ });
});
4. Keep Tests Isolated
// Good: Each test is independent
beforeEach (() => {
gameState = new GameState ();
});
// Bad: Tests depend on each other
let sharedState ;
it ( 'test 1' , () => {
sharedState = createState ();
});
it ( 'test 2' , () => {
// Depends on test 1
sharedState . modify ();
});
5. Use Factories for Test Data
export function createTestPlayer ( overrides = {}) {
return {
id: 'player1' ,
name: 'Test Player' ,
territories: [],
troops: 100 ,
... overrides ,
};
}
export function createTestGame ( overrides = {}) {
return {
seed: 12345 ,
players: [ createTestPlayer ()],
turn: 0 ,
... overrides ,
};
}
Usage:
it ( 'handles multiple players' , () => {
const game = createTestGame ({
players: [
createTestPlayer ({ id: 'player1' }),
createTestPlayer ({ id: 'player2' }),
],
});
expect ( game . players . length ). toBe ( 2 );
});
Mocking and Stubbing
Vitest provides mocking utilities:
import { describe , it , expect , vi } from 'vitest' ;
// Mock a module
vi . mock ( '@/server/api' , () => ({
fetchPlayerData: vi . fn (() => Promise . resolve ({ id: '123' })),
}));
// Spy on a method
const spy = vi . spyOn ( gameState , 'updateTerritory' );
// Mock timers
vi . useFakeTimers ();
vi . advanceTimersByTime ( 1000 );
vi . useRealTimers ();
Run performance benchmarks:
This executes tests in /tests/perf using the Benchmark.js library:
tests/perf/pathfinding.perf.ts
import Benchmark from 'benchmark' ;
import { findPath } from '@/core/pathfinding' ;
const suite = new Benchmark . Suite ();
suite
. add ( 'Pathfinding: Small map' , () => {
findPath ( smallMap , start , end );
})
. add ( 'Pathfinding: Large map' , () => {
findPath ( largeMap , start , end );
})
. on ( 'complete' , function () {
console . log ( 'Fastest is ' + this . filter ( 'fastest' ). map ( 'name' ));
})
. run ();
Continuous Integration
OpenFront uses GitHub Actions for CI:
name : CI
on : [ push , pull_request ]
jobs :
test :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v3
- uses : actions/setup-node@v3
- run : npm run inst
- run : npm test
- run : npm run lint
All PRs must pass CI checks before being merged.
Common Testing Patterns
Testing Alliances
tests/AllianceSystem.test.ts
import { describe , it , expect , beforeEach } from 'vitest' ;
import { GameState } from '@/core/state' ;
describe ( 'Alliance System' , () => {
let game : GameState ;
beforeEach (() => {
game = new GameState ({
players: [
createTestPlayer ({ id: 'p1' }),
createTestPlayer ({ id: 'p2' }),
],
});
});
it ( 'allows alliance requests' , () => {
const intent = createAllianceRequestIntent ( 'p1' , 'p2' );
const execution = new AllianceRequestExecution ( intent );
execution . execute ( game );
expect ( game . allianceRequests ). toContainEqual ({
from: 'p1' ,
to: 'p2' ,
});
});
it ( 'creates alliance when accepted' , () => {
game . createAllianceRequest ( 'p1' , 'p2' );
game . acceptAllianceRequest ( 'p2' , 'p1' );
expect ( game . alliances ). toContainEqual ({
members: [ 'p1' , 'p2' ],
});
});
});
Testing Game Tick Execution
it ( 'executes all intents in a tick' , () => {
const turn = createTurn ([
createMoveIntent ( 'p1' , territoryA , territoryB ),
createAttackIntent ( 'p2' , territoryC , territoryD ),
]);
game . processTurn ( turn );
game . executeNextTick ();
expect ( game . territories [ territoryB ]. owner ). toBe ( 'p1' );
expect ( game . territories [ territoryD ]. troops ). toBeLessThan ( 100 );
});
Debugging Tests
Using VS Code
Add to .vscode/launch.json:
{
"type" : "node" ,
"request" : "launch" ,
"name" : "Debug Vitest Tests" ,
"runtimeExecutable" : "npm" ,
"runtimeArgs" : [ "test" , "--" , "--run" ],
"console" : "integratedTerminal" ,
"internalConsoleOptions" : "neverOpen"
}
Console Logging
it ( 'debugs game state' , () => {
const game = createTestGame ();
console . log ( 'Initial state:' , game );
game . executeAction ();
console . log ( 'After action:' , game );
expect ( game . someProperty ). toBe ( expectedValue );
});
Troubleshooting
Tests fail with module resolution errors
Ensure tsconfig.json paths are correctly set and Vitest is using vite-tsconfig-paths.
Flaky tests (sometimes pass, sometimes fail)
This usually indicates:
Non-deterministic code (missing seed)
Timing issues (use vi.useFakeTimers())
Shared state between tests (use beforeEach)
Delete the coverage directory and re-run: rm -rf coverage
npm run test:coverage
Increase timeout for slow tests: it ( 'slow operation' , async () => {
// ...
}, 10000 ); // 10 second timeout
Architecture Understand the system design
Setup Guide Set up development environment
Contributing Contribution guidelines