Steven's Knowledge

Testing

A test pyramid for Electron — unit tests with a mocked electron module, IPC mocking, renderer component tests, and end-to-end with Playwright or WebdriverIO

Testing

Electron testing follows the same pyramid as the web — many fast unit tests, fewer component tests, a handful of end-to-end runs — with one twist: the slow, brittle tier (E2E) is the only place that exercises the main process, native modules, and the IPC boundary for real, so you want to push as much logic as possible below it.

The Pyramid

        ╱ E2E ╲          few — launch the real app (Playwright _electron)
       ╱───────╲
      ╱ Component╲       some — renderer UI in jsdom (Testing Library)
     ╱─────────────╲
    ╱   Unit / IPC   ╲   many — pure logic + mocked electron / IPC handlers
   ╱───────────────────╲

The guiding principle is the same one that drives good Electron architecture: keep business logic out of Electron-coupled files. A function that takes plain arguments and returns plain data is trivially unit-testable; a function that reaches for app, BrowserWindow, or ipcMain is not. Push the latter to thin adapters.

Unit Tests

Pure logic — parsing, validation, state machines, formatting — needs no Electron at all. Test it with Vitest or Jest like any Node module.

// src/shared/version.ts
export function isNewer(a: string, b: string): boolean {
  const pa = a.split('.').map(Number)
  const pb = b.split('.').map(Number)
  for (let i = 0; i < 3; i++) {
    if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0)
  }
  return false
}
// version.test.ts
import { describe, it, expect } from 'vitest'
import { isNewer } from './version'

describe('isNewer', () => {
  it('compares semver', () => {
    expect(isNewer('1.2.0', '1.1.9')).toBe(true)
    expect(isNewer('1.0.0', '1.0.0')).toBe(false)
  })
})

Mocking the electron module

To unit-test main-process code that imports electron, mock the module. Vitest's vi.mock (or Jest's jest.mock) replaces it with stubs so no real Electron runtime is needed.

import { describe, it, expect, vi, beforeEach } from 'vitest'

vi.mock('electron', () => ({
  app: {
    getPath: vi.fn(() => '/tmp/userData'),
    getVersion: vi.fn(() => '1.2.3'),
  },
  ipcMain: { handle: vi.fn() },
}))

import { app } from 'electron'
import { resolveConfigPath } from './config'

describe('resolveConfigPath', () => {
  beforeEach(() => vi.clearAllMocks())

  it('builds the path under userData', () => {
    expect(resolveConfigPath('settings.json')).toBe('/tmp/userData/settings.json')
    expect(app.getPath).toHaveBeenCalledWith('userData')
  })
})

IPC Testing

Test IPC handlers in isolation by registering them against a mocked ipcMain and invoking the captured handler directly. Keep the handler a thin wrapper around a pure function so the real work is unit-tested separately.

// src/main/ipc/files.ts
import { ipcMain } from 'electron'
import { readUserFile } from '../services/files'

export function registerFileIpc() {
  ipcMain.handle('file:read', async (_event, name: string) => {
    return readUserFile(name) // pure-ish service, separately tested
  })
}
// files.ipc.test.ts
import { describe, it, expect, vi } from 'vitest'

const handlers = new Map<string, Function>()
vi.mock('electron', () => ({
  ipcMain: { handle: (ch: string, fn: Function) => handlers.set(ch, fn) },
}))
vi.mock('../services/files', () => ({ readUserFile: vi.fn(async () => 'hi') }))

import { registerFileIpc } from './files'

describe('file:read handler', () => {
  it('returns service result', async () => {
    registerFileIpc()
    const handler = handlers.get('file:read')!
    await expect(handler({}, 'note.txt')).resolves.toBe('hi')
  })
})

Renderer Component Tests

The renderer is a normal web app, so component tests are exactly the web setup: Testing Library plus jsdom under Vitest. Mock the window.api bridge the preload would expose so components run without a real main process.

// setup: stub the preload-exposed API
import { vi, beforeEach } from 'vitest'

beforeEach(() => {
  ;(globalThis as any).window.api = {
    ping: vi.fn(async () => 'pong'),
    readFile: vi.fn(async () => 'contents'),
  }
})
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { FileViewer } from './FileViewer'

it('loads file via the bridge', async () => {
  render(<FileViewer name="note.txt" />)
  fireEvent.click(screen.getByText('Open'))
  await waitFor(() => expect(screen.getByText('contents')).toBeInTheDocument())
  expect(window.api.readFile).toHaveBeenCalledWith('note.txt')
})

End-to-End

E2E launches the actual built app and drives it. This is the only tier that proves main + preload + renderer + IPC work together.

Playwright has official Electron support via its _electron API. Launch the app, grab the first window, and assert against it with the full Playwright API.

import { test, expect, _electron as electron } from '@playwright/test'

test('launches and shows the main window', async () => {
  const app = await electron.launch({ args: ['.'] }) // or path to packaged binary
  const window = await app.firstWindow()

  await expect(window).toHaveTitle(/MyApp/)
  await window.click('text=New File')
  await expect(window.locator('.editor')).toBeVisible()

  // assert a main-process value via evaluate
  const isPackaged = await app.evaluate(({ app }) => app.isPackaged)
  expect(typeof isPackaged).toBe('boolean')

  await app.close()
})

app.evaluate runs in the main process, which is how you assert main-side state or invoke main APIs directly from a test.

WebdriverIO

WebdriverIO with wdio-electron-service is the other mature option, built on the WebDriver protocol with helpers for mocking Electron APIs from the test. Choose it if you already use the WDIO ecosystem; otherwise Playwright is the lighter setup.

Do not use Spectron. It is deprecated and unmaintained, and it relied on the removed remote module. Use Playwright's _electron API or WebdriverIO with wdio-electron-service instead.

Testing across the boundary

The valuable E2E assertions cross the IPC boundary: drive the renderer UI, then verify a main-process side effect. Two reliable techniques:

  • Read the artifact. If the action writes a file or DB row, open the app with a temp userData dir and assert the file on disk after the UI action.
  • Expose a test hook. Use app.evaluate to read main-process state, or register a test-only IPC channel (guarded by an env flag) that reports internal state back.
const app = await electron.launch({
  args: ['.'],
  env: { ...process.env, APP_USER_DATA: tmpDir }, // isolate state per test
})
await window.click('text=Save')
// then read tmpDir/settings.json and assert

Headless CI

Linux CI runners have no display, and Electron needs one. Wrap the test command in xvfb-run (a virtual framebuffer). macOS and Windows runners run headed, so no wrapper is needed there.

# Linux only
xvfb-run --auto-servernum npx playwright test
# GitHub Actions step (Linux)
- run: xvfb-run --auto-servernum pnpm test:e2e
  if: runner.os == 'Linux'
- run: pnpm test:e2e
  if: runner.os != 'Linux'

Strategy

Test typeToolSpeedScope
Unit (logic)Vitest / Jestvery fast (ms)one function/module, no Electron
Unit (main, mocked)Vitest + vi.mock('electron')fastmain module against stubbed APIs
IPC handlerVitest + mocked ipcMainfastone channel handler in isolation
ComponentTesting Library + jsdomfastrenderer UI, mocked window.api
E2EPlaywright _electron / WebdriverIOslow (seconds)whole app, real IPC + main

Aim for many unit tests, a moderate set of component tests, and a few critical-path E2E flows: app launch, the core user journey, and an auto-update smoke check. Keep E2E focused — it is slow and the most flaky.

Coverage of the main process is hard to measure. Standard JS coverage tools instrument the V8 isolate they run in; spawning the main process under E2E does not report into your unit coverage. Treat main-process coverage as best-effort and rely on extracting logic into separately covered modules rather than chasing a coverage number from E2E.

On this page