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 (recommended)
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
userDatadir and assert the file on disk after the UI action. - Expose a test hook. Use
app.evaluateto 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 assertHeadless 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 type | Tool | Speed | Scope |
|---|---|---|---|
| Unit (logic) | Vitest / Jest | very fast (ms) | one function/module, no Electron |
| Unit (main, mocked) | Vitest + vi.mock('electron') | fast | main module against stubbed APIs |
| IPC handler | Vitest + mocked ipcMain | fast | one channel handler in isolation |
| Component | Testing Library + jsdom | fast | renderer UI, mocked window.api |
| E2E | Playwright _electron / WebdriverIO | slow (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.