GameLord uses Vitest for unit and integration testing. The test suite covers IPC handlers, library services, emulation logic, and UI components.
Running tests
Run all tests
# From root directory
pnpm test
# Or use Turbo
pnpm turbo test
This runs tests across all workspaces (apps/desktop and packages/ui).
Run tests in watch mode
cd apps/desktop
pnpm test:watch
Tests re-run automatically when files change.
Run specific test files
# Run a single test file
pnpm vitest run src/main/services/LibraryService.test.ts
# Run tests matching a pattern
pnpm vitest run --grep "LibraryService"
Run with coverage
pnpm vitest run --coverage
Coverage report is generated in coverage/ directory.
Coverage thresholds are not enforced yet. Focus on testing critical paths: IPC handlers, state management, and native addon interactions.
Test configuration
Vitest is configured in vitest.config.ts:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom', // Lightweight DOM environment
globals: true, // No need to import describe, it, expect
include: ['src/**/*.test.{ts,tsx}'],
exclude: ['**/node_modules/**'],
},
})
We use happy-dom instead of jsdom for faster test execution. It provides sufficient DOM APIs for most React component tests.
Test structure
Tests are co-located with source files:
apps/desktop/src/
├── main/
│ ├── services/
│ │ ├── LibraryService.ts
│ │ ├── LibraryService.test.ts # Unit tests
│ │ ├── ArtworkService.ts
│ │ └── ArtworkService.test.ts
│ ├── emulator/
│ │ ├── EmulatorManager.ts
│ │ ├── EmulatorManager.test.ts
│ │ ├── LibretroNativeCore.ts
│ │ └── LibretroNativeCore.test.ts
│ └── ipc/
│ ├── handlers.ts
│ └── handlers.test.ts # IPC handler tests
└── renderer/
├── hooks/
│ ├── useGamepad.ts
│ └── useGamepad.test.ts
└── components/
├── GameWindow.tsx
└── GameWindow.test.tsx # React component tests
Writing tests
Basic structure
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
describe('LibraryService', () => {
let service: LibraryService
beforeEach(() => {
// Setup: create fresh instance
service = new LibraryService()
})
afterEach(() => {
// Cleanup: clear mocks, close connections
vi.clearAllMocks()
})
it('should scan directory and find ROMs', async () => {
// Arrange
const romDir = '/path/to/test/roms'
// Act
const games = await service.scanDirectory(romDir)
// Assert
expect(games).toHaveLength(10)
expect(games[0].systemId).toBe('nes')
})
})
Mocking Electron APIs
import { vi } from 'vitest'
// Mock electron module
vi.mock('electron', () => ({
app: {
getPath: vi.fn((name: string) => {
if (name === 'userData') return '/tmp/test-user-data'
if (name === 'home') return '/tmp/test-home'
return '/tmp'
}),
},
ipcMain: {
handle: vi.fn(),
on: vi.fn(),
},
}))
Mocking the native addon
The native addon can’t be loaded in tests, so mock it:
import { vi } from 'vitest'
vi.mock('../native/gamelord_libretro.node', () => ({
LibretroCore: vi.fn().mockImplementation(() => ({
loadCore: vi.fn().mockReturnValue(true),
loadGame: vi.fn().mockReturnValue(true),
run: vi.fn(),
getVideoFrame: vi.fn().mockReturnValue({
data: new Uint8Array(256 * 240 * 4),
width: 256,
height: 240,
}),
getAudioBuffer: vi.fn().mockReturnValue(new Int16Array(1024)),
serializeState: vi.fn().mockReturnValue(new Uint8Array(1024)),
unserializeState: vi.fn().mockReturnValue(true),
getMemoryData: vi.fn().mockReturnValue(new Uint8Array(8192)),
})),
}))
Testing async operations
it('should download core from buildbot', async () => {
const downloader = new CoreDownloader()
// Mock https.get
vi.mock('https', () => ({
get: vi.fn((url, callback) => {
callback({
pipe: vi.fn(),
on: vi.fn(),
})
return { on: vi.fn() }
}),
}))
await expect(downloader.downloadCore('fceumm_libretro', 'nes'))
.resolves
.toContain('/cores/fceumm_libretro.dylib')
})
Testing React components
import { render, screen, fireEvent } from '@testing-library/react'
import { GameCard } from './GameCard'
it('should render game title and cover art', () => {
const game = {
id: '1',
title: 'Super Mario Bros.',
systemId: 'nes',
coverArt: 'artwork://123',
}
render(<GameCard game={game} />)
expect(screen.getByText('Super Mario Bros.')).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute('src', 'artwork://123')
})
it('should emit play event on click', () => {
const onPlay = vi.fn()
const game = { id: '1', title: 'Game', systemId: 'nes' }
render(<GameCard game={game} onPlay={onPlay} />)
fireEvent.click(screen.getByRole('button', { name: /play/i }))
expect(onPlay).toHaveBeenCalledWith(game)
})
Testing custom hooks
import { renderHook, act } from '@testing-library/react'
import { useGamepad } from './useGamepad'
it('should detect gamepad connection', () => {
const { result } = renderHook(() => useGamepad())
expect(result.current.connected).toBe(false)
act(() => {
// Simulate gamepad connection
window.dispatchEvent(new Event('gamepadconnected'))
})
expect(result.current.connected).toBe(true)
})
Test coverage
Current coverage
IPC handlers
✅ Fully covered
LibraryService
✅ Scanning, hashing, deduplication
LibretroNativeCore
✅ Save states, SRAM, error recovery
EmulationWorkerClient
✅ Worker lifecycle, IPC protocol
WebGL shaders
✅ Shader compilation, fallback behavior
ArtworkService
⚠️ Partial - API mocking needs work
GameWindow component
❌ Missing - needs integration testing
Native addon
❌ Not testable - C++ code requires manual testing
Priority areas for new tests
GameWindow integration
Test the full frame rendering pipeline: IPC event → buffer → WebGL draw → canvas.
Audio playback
Test Web Audio API integration: sample buffering, scheduling, underrun handling.
Input mapping
Test keyboard/gamepad input → IPC → worker → native addon state updates.
Error boundaries
Test React error boundaries catch renderer crashes gracefully.
Manual testing
Testing the native addon
The native addon requires manual testing with real cores and ROMs:
Build the addon
cd apps/desktop/native
npx node-gyp rebuild
Test core loading
- Open the library
- Add a ROM directory
- Launch a game
- Verify the game runs without crashes
Test save states
- Press F5 to save state (slot 1)
- Play for a bit
- Press F9 to load state
- Verify game restored to save point
Test SRAM persistence
- Play a game with battery saves (e.g., Pokémon)
- Save in-game
- Close the game
- Relaunch the game
- Verify save is preserved
Check logs for errors
tail -f ~/Library/Logs/GameLord/main.log
Testing frame pacing
Use the FPS counter to verify smooth frame delivery:
- Enable FPS counter in Settings (or press Cmd+Shift+F)
- Launch a game
- Monitor FPS - should be stable at ~60 (±1)
- Check for stuttering during gameplay
On 120Hz displays, the renderer will draw at 120Hz but the core still runs at 60Hz. The FPS counter shows renderer FPS, not emulation FPS.
# Launch with remote debugging
pnpm start
# Open chrome://inspect in Chrome/Edge
# Click "inspect" on the renderer target
# Go to Performance tab → Record
Debugging test failures
View test output
# Run with verbose output
pnpm vitest run --reporter=verbose
# Run a single failing test
pnpm vitest run --reporter=verbose src/path/to/test.test.ts
Debug in VS Code
Add a launch configuration in .vscode/launch.json:
{
"type": "node",
"request": "launch",
"name": "Debug Vitest Tests",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["vitest", "run", "--no-coverage"],
"cwd": "${workspaceFolder}/apps/desktop",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
Set breakpoints in test files and press F5.
Common failure patterns
Likely waiting for an async operation that never resolves:// Bad: Promise never resolves
await service.someAsyncMethod()
// Good: Add timeout
await service.someAsyncMethod().timeout(5000)
// Or mock the async dep
vi.mock('./someModule', () => ({
someAsyncMethod: vi.fn().mockResolvedValue(result),
}))
'Cannot read property X of undefined'
Mock is missing properties:// Bad: Incomplete mock
const mockCore = { loadCore: vi.fn() }
// Good: Mock all used properties
const mockCore = {
loadCore: vi.fn().mockReturnValue(true),
loadGame: vi.fn().mockReturnValue(true),
run: vi.fn(),
// ... all methods used in the test
}
'Module not found' in test
Path resolution differs in tests. Use absolute paths or configure Vitest aliases:// vitest.config.ts
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
CI/CD testing
GitHub Actions runs tests on every PR:
- name: Run tests
run: pnpm turbo test
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
Tests must pass before merging.
Testing guidelines
Test behavior, not implementation
Focus on inputs and outputs, not internal details. This makes tests resilient to refactoring.
One assertion per test
Each test should verify one behavior. Multiple tests with clear names are better than one test with many assertions.
Use descriptive test names
should emit scanProgress event for each discovered game is better than test scan.
Mock external dependencies
Tests should be fast and deterministic. Mock filesystem, network, and native addon.
Next steps
Architecture
Understand the system design to write better integration tests
Building
Build and package GameLord for distribution