Skip to main content
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

1

GameWindow integration

Test the full frame rendering pipeline: IPC event → buffer → WebGL draw → canvas.
2

Audio playback

Test Web Audio API integration: sample buffering, scheduling, underrun handling.
3

Input mapping

Test keyboard/gamepad input → IPC → worker → native addon state updates.
4

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:
1

Build the addon

cd apps/desktop/native
npx node-gyp rebuild
2

Run the app

cd ../..
pnpm start
3

Test core loading

  • Open the library
  • Add a ROM directory
  • Launch a game
  • Verify the game runs without crashes
4

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
5

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
6

Check logs for errors

tail -f ~/Library/Logs/GameLord/main.log

Testing frame pacing

Use the FPS counter to verify smooth frame delivery:
  1. Enable FPS counter in Settings (or press Cmd+Shift+F)
  2. Launch a game
  3. Monitor FPS - should be stable at ~60 (±1)
  4. 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.

Performance profiling

# 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),
}))
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
}
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

Build docs developers (and LLMs) love