Skip to main content

Testing

True Life uses Jest for comprehensive testing across all runtimes. The testing framework supports unit tests, server/client tests with FiveM mocks, simulated in-game tests, full E2E scenarios, and React UI tests.

Overview

Test TypeFile PatternCommandPurposeScope
Unit*.test.ts, *.spec.tspnpm test:unitPure logic, no FiveM contextSingle function/class
Server*.server.test.tspnpm test:serverServer-side unit tests with mocked nativesServer runtime in isolation
Client*.client.test.tspnpm test:clientClient-side unit tests with mocked nativesClient runtime in isolation
Simulated*.simulated.test.tspnpm test:simulatedIntegration tests across client/server/webCross-runtime flows (CI/CD)
E2E*.e2e.test.tspnpm test:e2eFull system scenarios with multi-client simComplex user journeys
Web*.test.tsx, *.spec.tsxpnpm test:webReact component tests with jsdomUI components

When to Use Each

  • Unit: Pure functions, utilities, type transformations, algorithms
  • Server/Client: Runtime-specific behavior in isolation (observables, natives, entity wrappers)
  • Simulated: RPC flows, cross-runtime state sync, service interactions — runs in CI/CD
  • E2E: Multi-player scenarios, event timelines, complex user journeys
  • Web: React components, hooks, UI state, DOM interactions

Quick Start

# Run all tests
pnpm test
 
# Run specific test type
pnpm test:unit
pnpm test:server
pnpm test:client
pnpm test:simulated
pnpm test:e2e
pnpm test:web
 
# Watch mode
pnpm test:watch
pnpm test:unit:watch
 
# Coverage
pnpm test:coverage
 
# CI mode (optimized for CI environments)
pnpm test:ci

Directory Structure

test/
├── setup/                    # Jest setup files
│   ├── unit.setup.ts        # Unit test environment
│   ├── server.setup.ts      # Server environment with mocks
│   ├── client.setup.ts      # Client environment with mocks
│   ├── simulated.setup.ts   # Simulated RPC test setup
│   ├── e2e.setup.ts         # E2E test setup
│   └── web.setup.ts         # Web/UI test setup
├── mocks/                    # Mock implementations
│   ├── fivem/
│   │   └── natives.ts       # FiveM native function mocks
│   ├── rpc/
│   │   └── mock-rpc.ts      # RPC system mock
│   └── di/
│       └── mock-di.ts       # Dependency injection mock
├── harness/                  # Test harnesses
│   ├── simulated-harness.ts # Client/server simulation
│   └── e2e-harness.ts       # Multi-client E2E
├── utils/                    # Test utilities
│   └── react-testing.ts     # React testing helpers
├── examples/                 # Example tests
│   ├── unit.example.test.ts
│   ├── simulated.example.test.ts
│   └── e2e.example.test.ts
├── index.ts                  # Main exports
└── README.md                 # Quick reference

Unit Tests

For testing pure logic without FiveM dependencies.

Basic Example

// src/modules/my-module/utils.test.ts
import { describe, it, expect } from "@jest/globals";
import { calculateTax, formatCurrency } from "./utils";
 
describe("calculateTax", () => {
	it("should calculate 10% tax", () => {
		expect(calculateTax(100, 0.1)).toBe(10);
	});
 
	it("should handle zero amount", () => {
		expect(calculateTax(0, 0.1)).toBe(0);
	});
});
 
describe("formatCurrency", () => {
	it("should format with dollar sign", () => {
		expect(formatCurrency(1234.56)).toBe("$1,234.56");
	});
});

Testing with Mocks

import { describe, it, expect, jest, beforeEach } from "@jest/globals";
import { NotificationManager } from "./notification-manager";
 
describe("NotificationManager", () => {
	let manager: NotificationManager;
 
	beforeEach(() => {
		manager = new NotificationManager();
		jest.useFakeTimers();
	});
 
	it("should auto-dismiss after duration", () => {
		manager.show({ message: "Test", duration: 5000 });
 
		expect(manager.getNotifications()).toHaveLength(1);
 
		jest.advanceTimersByTime(5000);
 
		expect(manager.getNotifications()).toHaveLength(0);
	});
});

Server Tests (Runtime Unit)

For testing server-side logic in isolation with FiveM native mocks. Use these for testing repositories, server observables, player state management, and database operations without needing cross-runtime communication.

Basic Server Test

// src/modules/my-module/my.service.server.test.ts
import { describe, it, expect, beforeEach } from "@jest/globals";
import { createServiceInstance } from "@test/mocks/di/mock-di";
import { addMockPlayer } from "@test/mocks/fivem/natives";
import { MyService } from "./my.service";
 
describe("MyService (server)", () => {
	let service: MyService;
 
	beforeEach(() => {
		// Create service with DI support
		service = createServiceInstance(MyService);
 
		// Add mock players
		addMockPlayer(1, "TestPlayer", ["license:test123", "discord:123456"]);
	});
 
	it("should get player data", async () => {
		const result = await service.getPlayerData({
			source: 1,
			args: ["test-id"],
		});
 
		expect(result).toBeDefined();
		expect(result.playerId).toBe(1);
	});
 
	it("should validate player permissions", async () => {
		const hasPermission = await service.checkPermission({
			source: 1,
			args: ["admin"],
		});
 
		expect(hasPermission).toBe(false);
	});
});

Testing with Database Mocks

import { describe, it, expect, beforeEach, jest } from "@jest/globals";
import { createServiceInstance, registerMockService } from "@test/mocks/di/mock-di";
import { BankingService } from "./banking.service";
import { BankingRepository } from "./repository";
 
// Create mock repository
const mockRepository = {
	findAccount: jest.fn(),
	updateBalance: jest.fn(),
};
 
describe("BankingService (server)", () => {
	let service: BankingService;
 
	beforeEach(() => {
		// Register mock repository
		registerMockService(BankingRepository, mockRepository);
 
		// Create service (will use mock repository via DI)
		service = createServiceInstance(BankingService);
 
		// Reset mocks
		jest.clearAllMocks();
	});
 
	it("should transfer funds between accounts", async () => {
		mockRepository.findAccount.mockResolvedValue({ id: "1", balance: 1000 });
		mockRepository.updateBalance.mockResolvedValue(true);
 
		const result = await service.transfer({
			source: 1,
			args: ["account-1", "account-2", 100],
		});
 
		expect(result.success).toBe(true);
		expect(mockRepository.updateBalance).toHaveBeenCalledTimes(2);
	});
});

Client Tests (Runtime Unit)

For testing client-side logic in isolation with FiveM native mocks. Use these for testing entity wrappers, client observables, native calls, and input handling without needing cross-runtime communication.

Basic Client Test

// src/modules/my-module/my.service.client.test.ts
import { describe, it, expect, beforeEach } from "@jest/globals";
import { createServiceInstance } from "@test/mocks/di/mock-di";
import { setCurrentPlayerId, setCurrentPedId, setMockPedHealth } from "@test/mocks/fivem/natives";
import { PlayerService } from "./player.service";
 
describe("PlayerService (client)", () => {
	let service: PlayerService;
 
	beforeEach(() => {
		service = createServiceInstance(PlayerService);
 
		// Setup client state
		setCurrentPlayerId(1);
		setCurrentPedId(1000);
		setMockPedHealth(1000, 150, 200);
	});
 
	it("should get current player health", () => {
		const health = service.getCurrentHealth();
		expect(health).toBe(150);
	});
 
	it("should detect low health state", () => {
		setMockPedHealth(1000, 25, 200);
 
		const isLowHealth = service.isLowHealth();
		expect(isLowHealth).toBe(true);
	});
});

Testing NUI Communication

import { describe, it, expect, beforeEach } from "@jest/globals";
import { createServiceInstance } from "@test/mocks/di/mock-di";
import { getNuiMessageQueue, clearNuiMessageQueue } from "@test/mocks/fivem/natives";
import { HudService } from "./hud.service";
 
describe("HudService (client)", () => {
	let service: HudService;
 
	beforeEach(() => {
		service = createServiceInstance(HudService);
		clearNuiMessageQueue();
	});
 
	it("should send NUI message when updating HUD", () => {
		service.updateHudState({ visible: true, data: "test" });
 
		const messages = getNuiMessageQueue();
		expect(messages).toHaveLength(1);
		expect(messages[0].type).toBe("hud:update");
		expect(messages[0].data).toMatchObject({ visible: true });
	});
});

Simulated Tests (Integration)

For testing cross-runtime flows (RPC communication, observable sync, service interactions) in a fully mocked environment. These tests simulate all three runtime environments (client/server/web) in a single process, making them ideal for CI/CD pipelines.

Basic Simulated Test

// test/simulated/my-feature.simulated.test.ts
import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
import { createSimulatedHarness, type SimulatedHarness } from "@test/harness/simulated-harness";
import { MyService } from "@modules/my-module/my.service";
 
describe("MyFeature (simulated)", () => {
	let harness: SimulatedHarness;
 
	beforeEach(() => {
		harness = createSimulatedHarness();
	});
 
	afterEach(() => {
		harness.dispose();
	});
 
	it("should handle client-to-server RPC", async () => {
		// Create services on server and client
		const serverService = harness.server.createService(MyService);
		const client = harness.createClient(1);
		const clientService = client.createService(MyService);
 
		// Simulate player joining
		await harness.server.triggerPlayerJoining(1);
 
		// Make RPC call from client to server
		const result = await clientService.fetchData({
			source: 0, // 0 = server
			args: ["test-id"],
		});
 
		// Verify result
		expect(result).toBeDefined();
 
		// Verify RPC was called
		harness.expectRpcCalled("MyService:fetchData");
	});
 
	it("should handle server-to-client RPC", async () => {
		const serverService = harness.server.createService(MyService);
		const client = harness.createClient(1);
		client.createService(MyService);
 
		await harness.server.triggerPlayerJoining(1);
 
		// Server sends notification to client
		await serverService.notifyPlayer({
			source: 1, // Target player ID
			args: ["Hello!"],
			noResponse: true,
		});
 
		// Verify RPC was called
		harness.expectRpcCalled("MyService:notifyPlayer");
	});
});

Testing Multiple Players

describe("Multiplayer interaction (simulated)", () => {
	let harness: SimulatedHarness;
 
	beforeEach(() => {
		harness = createSimulatedHarness();
	});
 
	afterEach(() => {
		harness.dispose();
	});
 
	it("should handle trade between players", async () => {
		// Setup server service
		const serverService = harness.server.createService(TradeService);
 
		// Create two clients
		const client1 = harness.createClient(1);
		const client2 = harness.createClient(2);
 
		const clientService1 = client1.createService(TradeService);
		const clientService2 = client2.createService(TradeService);
 
		// Players join
		await harness.server.triggerPlayerJoining(1);
		await harness.server.triggerPlayerJoining(2);
 
		// Player 1 initiates trade
		await clientService1.requestTrade({
			source: 0,
			args: [2], // Target player 2
		});
 
		// Player 2 accepts
		await clientService2.acceptTrade({
			source: 0,
			args: [1], // From player 1
		});
 
		// Verify trade RPCs
		harness.expectRpcCalled("TradeService:requestTrade");
		harness.expectRpcCalled("TradeService:acceptTrade");
	});
});

Simulated Harness API

interface SimulatedHarness {
	// Server environment
	server: {
		createService<T>(ServiceClass: new () => T): T;
		triggerPlayerJoining(playerId: number): Promise<void>;
		triggerPlayerDropped(playerId: number, reason?: string): Promise<void>;
		getService<T>(ServiceClass: new () => T): T | undefined;
	};
 
	// Create client environments
	createClient(playerId: number): ClientEnvironment;
	getClient(playerId: number): ClientEnvironment | undefined;
 
	// RPC tracking
	expectRpcCalled(eventName: string, times?: number): void;
	expectRpcCalledWith(eventName: string, ...args: unknown[]): void;
	getRpcCalls(eventName?: string): RpcCall[];
	clearRpcCalls(): void;
 
	// Cleanup
	dispose(): void;
}
 
interface ClientEnvironment {
	playerId: number;
	createService<T>(ServiceClass: new () => T): T;
	getService<T>(ServiceClass: new () => T): T | undefined;
}

E2E Tests (System)

For testing complete multi-player scenarios including event timelines and UI state. These extend simulated tests with support for multiple client connections, event sequence assertions, and snapshot capabilities.

Basic E2E Test

// test/e2e/character-selection.e2e.test.ts
import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
import { createE2EHarness, type E2EHarness } from "@test/harness/e2e-harness";
import { CharacterService } from "@modules/core/characters/character.service";
 
describe("Character Selection (e2e)", () => {
	let harness: E2EHarness;
 
	beforeEach(() => {
		harness = createE2EHarness();
	});
 
	afterEach(() => {
		harness.dispose();
	});
 
	it("should complete character selection flow", async () => {
		// Connect player
		const player = await harness.connectClient(1, { name: "TestPlayer" });
 
		// Verify player joined
		expect(harness.server.getConnectedPlayers()).toContain(1);
 
		// Select character
		await player.performAction("CharacterService:selectCharacter", ["char-1"]);
 
		// Verify UI state updated
		const uiState = harness.getWebState(1);
		expect(uiState.selectedCharacter).toBe("char-1");
 
		// Verify event sequence
		harness.expectEventSequence([
			{ type: "client:created", playerId: 1 },
			{ type: "player:joined", playerId: 1 },
			{ type: "action:start", playerId: 1 },
			{ type: "action:complete", playerId: 1 },
		]);
	});
});

Multi-Client E2E Test

describe("Multiplayer E2E scenario", () => {
	let harness: E2EHarness;
 
	beforeEach(() => {
		harness = createE2EHarness();
	});
 
	afterEach(() => {
		harness.dispose();
	});
 
	it("should handle multiple players joining and interacting", async () => {
		// Connect multiple players
		const player1 = await harness.connectClient(1, { name: "Player1" });
		const player2 = await harness.connectClient(2, { name: "Player2" });
		const player3 = await harness.connectClient(3, { name: "Player3" });
 
		// All players see each other
		expect(harness.server.getConnectedPlayers()).toHaveLength(3);
 
		// Player 1 sends message
		await player1.performAction("ChatService:sendMessage", ["Hello everyone!"]);
 
		// Verify message was broadcast
		harness.expectRpcCalled("ChatService:broadcastMessage", 3);
 
		// Player 2 disconnects
		await harness.disconnectClient(2);
 
		expect(harness.server.getConnectedPlayers()).toHaveLength(2);
	});
});

E2E Harness API

interface E2EHarness extends SimulatedHarness {
	// Client connection
	connectClient(playerId: number, options?: ClientOptions): Promise<E2EClient>;
	disconnectClient(playerId: number): Promise<void>;
 
	// Web/UI state
	getWebState(playerId: number): Record<string, unknown>;
	setWebState(playerId: number, state: Record<string, unknown>): void;
 
	// Event timeline
	getEventTimeline(): TimelineEvent[];
	expectEventSequence(events: Partial<TimelineEvent>[]): void;
	clearTimeline(): void;
}
 
interface E2EClient {
	playerId: number;
	performAction(eventName: string, args: unknown[]): Promise<unknown>;
	getState(): ClientState;
}

Web/UI Tests (Component)

For testing React components, hooks, UI state, and DOM interactions using jsdom.

Basic Component Test

// ui/src/components/MyComponent.test.tsx
import { describe, it, expect, afterEach } from "@jest/globals";
import { renderComponent, screen, cleanup } from "@test/utils/react-testing";
import MyComponent from "./MyComponent";
 
describe("MyComponent", () => {
	afterEach(() => {
		cleanup();
	});
 
	it("should render correctly", () => {
		renderComponent(<MyComponent />);
		expect(screen.getByText("Hello World")).toBeInTheDocument();
	});
 
	it("should display prop value", () => {
		renderComponent(<MyComponent name="Test" />);
		expect(screen.getByText("Hello Test")).toBeInTheDocument();
	});
});

Testing with User Interactions

import { describe, it, expect, afterEach } from "@jest/globals";
import { renderComponent, screen, cleanup, fireEvent, waitForUpdate } from "@test/utils/react-testing";
import Counter from "./Counter";
 
describe("Counter", () => {
	afterEach(() => {
		cleanup();
	});
 
	it("should increment on button click", async () => {
		renderComponent(<Counter />);
 
		expect(screen.getByText("Count: 0")).toBeInTheDocument();
 
		fireEvent.click(screen.getByTestId("increment-btn"));
		await waitForUpdate();
 
		expect(screen.getByText("Count: 1")).toBeInTheDocument();
	});
 
	it("should handle input changes", async () => {
		renderComponent(<Counter />);
 
		fireEvent.change(screen.getByTestId("count-input"), { target: { value: "10" } });
		await waitForUpdate();
 
		expect(screen.getByText("Count: 10")).toBeInTheDocument();
	});
});

Testing with Services and Observables

import { describe, it, expect, afterEach, beforeEach } from "@jest/globals";
import { renderComponent, screen, cleanup, waitForUpdate, createTestObservable } from "@test/utils/react-testing";
import { registerMockService } from "@test/mocks/di/mock-di";
import { NeedsService } from "@modules/core/needs/needs.service";
import StatusPanel from "@modules/core/needs/huds/status";
 
// Create mock service with test observable
const createMockNeedsService = () => {
	const hudState = createTestObservable({ hunger: 100, thirst: 100, health: 200, armor: 0 });
	return { hudState };
};
 
describe("StatusPanel", () => {
	let mockNeedsService: ReturnType<typeof createMockNeedsService>;
 
	beforeEach(() => {
		mockNeedsService = createMockNeedsService();
		registerMockService(NeedsService, mockNeedsService);
	});
 
	afterEach(() => {
		cleanup();
	});
 
	it("should display needs values", async () => {
		renderComponent(<StatusPanel />);
 
		expect(screen.getByText("100%")).toBeInTheDocument(); // hunger
	});
 
	it("should update when observable changes", async () => {
		renderComponent(<StatusPanel />);
 
		// Update observable
		mockNeedsService.hudState.set({ hunger: 50, thirst: 75, health: 150, armor: 25 });
		await waitForUpdate();
 
		expect(screen.getByText("50%")).toBeInTheDocument();
	});
});

React Testing Utilities

// Available from @test/utils/react-testing
 
// Render a component (uses @testing-library/react)
function renderComponent(ui: ReactElement, options?: RenderOptions): RenderResult;
 
// Render with providers
function renderWithProviders(ui: ReactElement, providers: ComponentType[]): RenderResult;
 
// Clean up after tests
function cleanup(): void;
 
// Screen queries (re-exported from @testing-library/react)
const screen: Screen;
 
// Fire events (re-exported from @testing-library/react)
const fireEvent: FireEvent;
 
// Wait for state updates
function waitForUpdate(): Promise<void>;
 
// Create test observables
function createTestObservable<T>(initial: T): TestObservable<T>;
 
// Hook to use test observable in components
function useTestObservable<T>(observable: TestObservable<T>): T;
function TestProvider(props: { services: Record<string, unknown>; children: JSX.Element }): JSX.Element;

FiveM Native Mocks

The testing framework provides comprehensive mocks for FiveM natives, including support for the SWC native-inliner plugin output.

Native Inliner Support

The SWC native-inliner transforms native calls like:

// Original code
const health = GetEntityHealth(ped);

Into:

// Compiled output
const health = _in(hashHi, hashLo, ped, _r, _ri);

The mock system provides all the runtime helpers that FiveM normally provides:

HelperFiveM FunctionPurpose
_inCitizen.InvokeNativeInvoke native by hash
_iCitizen.PointerValueInt()Int pointer marker
_fCitizen.PointerValueFloat()Float pointer marker
_vCitizen.PointerValueVector()Vector3 pointer marker
_rCitizen.ReturnResultAnyway()Return result marker
_riCitizen.ResultAsInteger()Return as int
_rfCitizen.ResultAsFloat()Return as float
_rvCitizen.ResultAsVector()Return as Vector3
_sCitizen.ResultAsString()Return as string
_fv(helper)Convert to float value
_ts(helper)Convert to string (null-safe)
_ch(helper)String to hash conversion

Registering Custom Native Mocks

import { registerMockNative, joaat } from "@test/mocks/fivem/natives";
 
// Register by native name
registerMockNative("GET_ENTITY_HEALTH", (entity: number) => {
	return mockHealthMap.get(entity) ?? 200;
});
 
// Register by hash (hashHi, hashLo)
registerMockNative(3708994389, 1943371007, (value: number) => {
	return Math.abs(value); // ABSF native
});
 
// Use joaat for hashing strings
const propHash = joaat("prop_money_bag_01");

Mock State Manipulation

import {
	addMockPlayer,
	removeMockPlayer,
	setCurrentPlayerId,
	setCurrentServerId,
	setCurrentPedId,
	setMockPedHealth,
	setMockPedArmor,
	setMockEntityCoords,
	setMockConvar,
	advanceMockGameTimer,
	triggerNetEvent,
	triggerEvent,
} from "@test/mocks/fivem/natives";
 
// Add/remove players
addMockPlayer(1, "TestPlayer", ["license:test123"]);
removeMockPlayer(1);
 
// Set client state
setCurrentPlayerId(1);
setCurrentServerId(1);
setCurrentPedId(1000);
 
// Set entity state
setMockPedHealth(1000, 150, 200);
setMockPedArmor(1000, 50);
setMockEntityCoords(1000, 100.0, 200.0, 30.0);
 
// Convars
setMockConvar("debug_mode", "true");
 
// Time
advanceMockGameTimer(1000); // Advance by 1 second
 
// Trigger events
triggerNetEvent("myNetEvent", arg1, arg2);
triggerEvent("myLocalEvent", arg1);

RPC Mock System

The RPC mock system allows testing cross-runtime communication.

Basic Usage

import {
	createMockRpc,
	expectRpcCalled,
	expectRpcCalledWith,
	setRpcError,
	setRpcDelay,
	clearRpcCalls,
} from "@test/mocks/rpc/mock-rpc";
 
// Check if RPC was called
expectRpcCalled("MyService:getData");
expectRpcCalled("MyService:getData", 2); // Exactly 2 times
 
// Check call arguments
expectRpcCalledWith("MyService:getData", "arg1", "arg2");
 
// Simulate errors
setRpcError("MyService:getData", new Error("Network error"));
 
// Add delay for timing tests
setRpcDelay("MyService:getData", 100); // 100ms delay
 
// Clear tracking
clearRpcCalls();

Mocking RPC Responses

import { mockRpcHandler } from "@test/mocks/rpc/mock-rpc";
 
// Mock a specific RPC handler
mockRpcHandler("MyService:getData", async (ctx) => {
	const [id] = ctx.args;
	return { id, name: "Mock Data" };
});
 
// Mock with different responses per call
let callCount = 0;
mockRpcHandler("MyService:getData", async () => {
	callCount++;
	if (callCount === 1) return { first: true };
	return { first: false };
});

Dependency Injection Mock

For testing services with mocked dependencies.

Basic Usage

import {
	registerMockService,
	createServiceInstance,
	runInInjectionContext,
	clearMockServices,
} from "@test/mocks/di/mock-di";
 
// Register mock services
registerMockService(CharacterService, mockCharacterService);
registerMockService(BankingService, mockBankingService);
 
// Create service with DI (uses registered mocks)
const myService = createServiceInstance(MyService);
 
// Run code in injection context
runInInjectionContext(() => {
	const dep = inject(SomeDependency);
	// dep is the mock if registered, otherwise real instance
});
 
// Clear all mocks
clearMockServices();

Creating Mock Services

import { jest } from "@jest/globals";
 
// Create a mock service
const mockCharacterService = {
	getActiveCharacter: jest.fn().mockReturnValue({ id: "1", name: "Test" }),
	setActiveCharacter: jest.fn(),
	characters: {
		value: [{ id: "1", name: "Test" }],
		subscribe: jest.fn(),
	},
};
 
// Register and use
registerMockService(CharacterService, mockCharacterService);
const myService = createServiceInstance(MyService);
 
// Verify calls
expect(mockCharacterService.getActiveCharacter).toHaveBeenCalledWith(1);

Debugging Tests

Enable Debug Output

# Show console output during tests
DEBUG_TESTS=1 pnpm test:unit

Run Specific Tests

# Run specific file
pnpm test -- path/to/test.ts
 
# Run tests matching pattern
pnpm test -- --testNamePattern="should handle"
 
# Run only failed tests
pnpm test -- --onlyFailures

Watch Mode Options

# Watch specific files
pnpm test:watch -- path/to/test.ts
 
# Watch and run only related tests
pnpm test:watch -- --onlyChanged

Best Practices

1. Isolate Tests

Each test should be independent. Use beforeEach to set up fresh state:

let service: MyService;
 
beforeEach(() => {
	service = createServiceInstance(MyService);
	clearRpcCalls();
	resetMockState();
});

2. Clean Up Resources

Always dispose harnesses and unmount components:

afterEach(() => {
	harness?.dispose();
	cleanup(); // For UI tests
});

3. Use Appropriate Test Type

  • Unit tests → Pure logic, utilities, algorithms
  • Server/Client tests → Runtime-specific unit tests (observables, natives, entities)
  • Simulated tests → Integration tests for cross-runtime flows (RPC, state sync) — runs in CI/CD
  • E2E tests → System tests for multi-player scenarios and complex user journeys
  • Web tests → Component tests for React UI

4. Mock External Dependencies

// Mock database
registerMockService(Repository, mockRepository);
 
// Mock RPC responses
mockRpcHandler("ExternalService:getData", async () => mockData);
 
// Mock timers
jest.useFakeTimers();

5. Assert RPC Communication

// Verify RPC was called
harness.expectRpcCalled("MyService:getData");
 
// Verify with specific args
harness.expectRpcCalledWith("MyService:getData", "expected-arg");
 
// Verify call count
harness.expectRpcCalled("MyService:getData", 2);

6. Test Event Sequences (E2E)

harness.expectEventSequence([
	{ type: "player:joined", playerId: 1 },
	{ type: "character:selected", playerId: 1 },
	{ type: "spawn:complete", playerId: 1 },
]);

7. Wait for Async Updates

// For UI tests
await waitForUpdate();
 
// For RPC tests
await harness.flush(); // Process pending RPCs

Configuration

Jest Configuration

The Jest configuration is in jest.config.ts:

// jest.config.ts
export default {
	projects: [
		{
			displayName: "unit",
			testEnvironment: "node",
			testMatch: ["**/*.test.ts", "**/*.spec.ts"],
			testPathIgnorePatterns: [
				"\\.server\\.test\\.ts$",
				"\\.client\\.test\\.ts$",
				"\\.simulated\\.test\\.ts$",
				"\\.e2e\\.test\\.ts$",
			],
		},
		{
			displayName: "server",
			testEnvironment: "node",
			testMatch: ["**/*.server.test.ts"],
			setupFilesAfterEnv: ["<rootDir>/test/setup/server.setup.ts"],
		},
		// ... more projects
	],
};

TypeScript Paths

The @test/* path alias is configured in tsconfig.base.json:

{
	"compilerOptions": {
		"paths": {
			"@test/*": ["./test/*"]
		}
	}
}