DEV Community

Cover image for Testing Electron Apps: A Practical Guide to Unit, Integration, and E2E Coverage
mp3sss
mp3sss

Posted on AI-assisted

Testing Electron Apps: A Practical Guide to Unit, Integration, and E2E Coverage

Testing Electron Apps: A Practical Guide to Unit, Integration, and E2E Coverage

TL;DR: Electron apps are not just web apps — they are Node main processes, sandboxed renderers, a preload bridge, and an OS-native surface. Testing them with only a Vitest config and a hope will leave your IPC contracts and window lifecycle untested. This post lays out the four-layer testing strategy we actually ship with: unit tests for main-process logic, component tests for the renderer, IPC contract tests that lock the preload boundary, and Playwright E2E that launches the real Electron binary. It also tells you what not to test, so you don't spend three days fighting native dialogs.

Table of Contents

  1. Why Electron testing is different
  2. Layer 1 — Unit tests for main-process logic
  3. Layer 2 — Component tests for the renderer
  4. Layer 3 — IPC contract tests
  5. Layer 4 — E2E with Playwright
  6. What not to test
  7. Pitfalls we hit in production
  8. Checklist

Why Electron testing is different

A browser-only web app has one process and one event loop. An Electron app has at least:

  • a main process running Node.js (window lifecycle, file system, menus, tray);
  • one or more renderer processes running your web UI (sandboxed, no direct Node access);
  • a preload script that bridges the two via contextBridge;
  • and a pile of OS-native bits (dialogs, notifications, global shortcuts, auto-updater).

Each of these layers needs a different testing approach. If you only run renderer unit tests, your IPC handlers are untested. If you only run E2E, every test takes 20 seconds and you will delete the suite the first time it flakes. The goal is a pyramid: lots of fast tests at the bottom, a thin layer of slow E2E at the top.

Layer 1 — Unit tests for main-process logic

Main-process code should be plain TypeScript modules that take dependencies as arguments, not modules that import electron at the top level and call BrowserWindow.getAllWindows() on import. That shape is what makes it testable.

// src/main/project-service.ts
import { promises as fs } from 'node:fs';
import path from 'node:path';

export interface ProjectRepo {
  root: string;
}

export class ProjectService {
  constructor(private readonly fsImpl: typeof fs = fs) {}

  async listRecent(repo: ProjectRepo, limit = 10): Promise<string[]> {
    const entries = await this.fsImpl.readdir(repo.root, { withFileTypes: true });
    return entries
      .filter((e) => e.isDirectory())
      .map((e) => e.name)
      .slice(0, limit);
  }

  async ensureProjectDir(repo: ProjectRepo, name: string): Promise<string> {
    const dir = path.join(repo.root, name);
    await this.fsImpl.mkdir(dir, { recursive: true });
    return dir;
  }
}
Enter fullscreen mode Exit fullscreen mode

The fsImpl injection is the trick. In production you pass the real fs; in tests you pass an in-memory stub.

// test/main/project-service.test.ts
import { describe, it, expect } from 'vitest';
import { ProjectService } from '../../src/main/project-service';

function fakeFs(dirs: string[]) {
  return {
    readdir: async () => dirs.map((name) => ({ name, isDirectory: () => true })),
    mkdir: async () => undefined,
  } as any;
}

describe('ProjectService', () => {
  it('lists up to limit directories', async () => {
    const svc = new ProjectService(fakeFs(['a', 'b', 'c', 'd']));
    const out = await svc.listRecent({ root: '/repo' }, 2);
    expect(out).toEqual(['a', 'b']);
  });
});
Enter fullscreen mode Exit fullscreen mode

Run this with plain vitest run — no Electron binary, no display. It should finish in milliseconds.

Rule: Anything in src/main that does not touch BrowserWindow, app, or native modules should be importable under plain Node. If importing it requires a running Electron, you have coupled your business logic to the framework and need to refactor.

Layer 2 — Component tests for the renderer

The renderer is just a web app, so you already know how to test it: Vitest + your component testing library of choice. The only Electron-specific twist is that window.electron (your contextBridge exposure) does not exist in the test environment. Stub it globally.

// test/renderer/setup.ts
beforeEach(() => {
  (window as any).electron = {
    projects: {
      list: vi.fn().mockResolvedValue([{ name: 'demo', path: '/p/demo' }]),
      create: vi.fn().mockResolvedValue('/p/demo'),
    },
    shell: { openExternal: vi.fn() },
  };
});
Enter fullscreen mode Exit fullscreen mode

Then your component tests look exactly like web tests:

import { render, screen, fireEvent } from '@testing-library/react';
import { ProjectList } from '../../src/renderer/components/ProjectList';

it('renders projects from the IPC bridge', async () => {
  render(<ProjectList />);
  expect(await screen.findByText('demo')).toBeInTheDocument();
});

it('calls create when the new-project button is clicked', async () => {
  render(<ProjectList />);
  fireEvent.click(screen.getByRole('button', { name: /new project/i }));
  expect(window.electron.projects.create).toHaveBeenCalled();
});
Enter fullscreen mode Exit fullscreen mode

The benefit of stubbing at window.electron rather than mocking your own wrapper is that the preload contract is what you are testing against — if you rename a method in preload and forget to update the renderer, TypeScript will still compile because the stub is any, but Layer 3 below will catch it.

Layer 3 — IPC contract tests

This is the layer most teams skip, and it is the one that catches real bugs. The preload script is a contract between untrusted renderer code and privileged main-process code. Test that contract explicitly.

// test/ipc/contract.test.ts
import { describe, it, expect } from 'vitest';
import { contextBridge, ipcRenderer } from 'electron';

// We import the real preload source and run it against a fake contextBridge.
let exposed: Record<string, unknown> = {};
(contextBridge as any).exposeInMainWorld = (key: string, api: unknown) => {
  exposed[key] = api;
};
(ipcRenderer as any).invoke = async (channel: string, ...args: unknown[]) => {
  return { channel, args };
};

// eslint-disable-next-line @typescript-eslint/no-var-requires
require('../../src/preload/index.ts');

describe('preload contract', () => {
  it('exposes a typed projects API', () => {
    expect(exposed).toHaveProperty('electron.projects');
    const api = (exposed as any).electron;
    expect(typeof api.projects.list).toBe('function');
    expect(typeof api.projects.create).toBe('function');
    expect(typeof api.shell.openExternal).toBe('function');
  });

  it('does not leak raw ipcRenderer onto the bridge', () => {
    expect((exposed as any).electron).not.toHaveProperty('ipcRenderer');
  });

  it('routes list to the expected channel', async () => {
    const api = (exposed as any).electron;
    const res = await api.projects.list();
    expect(res.channel).toBe('projects:list');
  });
});
Enter fullscreen mode Exit fullscreen mode

The three assertions that matter:

  1. Every method you expect is exposed — renaming or dropping one breaks the build.
  2. Raw ipcRenderer is not on the bridge — a common security slip.
  3. Channel names match what ipcMain.handle registers — pair this with a test on the main side that enumerates registered handlers and asserts they are in the allowlist.

We also keep a single src/shared/ipc-channels.ts that exports channel name constants, and both preload and main import from it. No stringly-typed channels scattered across the codebase.

Layer 4 — E2E with Playwright

Unit and contract tests give you confidence in individual pieces. E2E gives you confidence that the real Electron binary, the real preload, and the real main process work together. Playwright has first-class Electron support, and you should use it.

// e2e/app.spec.ts
import { test, expect, _electron as electron } from '@playwright/test';
import { join } from 'node:path';

test('window opens and shows the project list', async () => {
  const app = await electron.launch({
    args: [join(__dirname, '..', '..', 'out', 'main', 'index.js')],
  });
  const win = await app.firstWindow();
  await win.waitForLoadState('domcontentloaded');

  await expect(win.locator('h1')).toContainText('Projects');
  await expect(win.locator('[data-testid="project-list"] .project')).toHaveCount(1);

  // exercise an IPC round-trip through the real bridge
  await win.getByRole('button', { name: /new project/i }).click();
  await expect(win.locator('.project')).toHaveCount(2);

  await app.close();
});
Enter fullscreen mode Exit fullscreen mode

A few things we learned the hard way:

  • Launch the built output, not electron . against source. Use your compiled out/main/index.js so the E2E is testing what you actually ship.
  • Use app.firstWindow() rather than guessing a URL. Electron windows do not have a useful URL for your main window in packaged builds.
  • waitForLoadState is your friend. Rushing assertions after launch is the #1 cause of flakes.
  • Give each E2E test a clean user-data dir via --user-data-dir=$TMPDIR/... so tests do not share state.
  • Do not click native OS dialogs from Playwright. If your flow opens a dialog.showOpenDialog, stub it from the main process in test mode (see next section).

What not to test

Some things look testable but are not worth the maintenance cost.

Surface Why skip it What to do instead
Native dialog.showOpenDialog / showMessageBox OS-level, platform-specific, unscriptable reliably Inject a dialogService interface; use a stub in tests
Auto-updater network calls Depends on external server, signed updates, real code signatures Unit-test the decision logic (should update? roll back?), never autoUpdater.checkForUpdates()
Tray icon rendering Pure OS compositor Screenshot once manually; do not assert pixels
Global shortcuts Hook into the OS input stack Test the registration/unregistration logic, not the keypress itself
Code signing / notarization External, slow, network-bound Verify on CI with a single dedicated job, not in PR tests
Crash reporter native dumps Requires an actual crash Unit-test the breadcrumb/redaction layer; send one real crash to staging manually

The pattern: test your logic, not the operating system. Wrap the native call behind a tiny interface, test your code that decides what to call, and let the integration points be covered by Layer 4 smoke tests only.

Pitfalls we hit in production

  1. __dirname is not what you expect in ESM builds. Electron has migrated to ESM; if your Playwright launch path is relative, you will silently start the wrong bundle. Use path.join(__dirname, ...) explicitly and log the resolved path in CI.
  2. Preload changes do not hot-reload. When you change src/preload, you must rebuild and restart Electron. Stale preloads are the #1 "works in unit, fails in E2E" bug.
  3. Sandbox enabled means your preload runs in a more restricted context. Node builtins you used in preload will not be available. Keep preload minimal — IPC channel names and argument validation only.
  4. Renderer fetch to local files is blocked by CSP. E2E tests that load file:// resources need the same CSP you ship in production, or you will green-test a broken build.
  5. Playwright and Electron versions must match. A Playwright built for Electron 30 will flake on Electron 33. Pin them together.
  6. app.getPath('userData') is shared across test runs. Two E2E tests writing to the same SQLite file will corrupt each other. Pass a fresh --user-data-dir per test worker.

Checklist

  • [ ] Business logic in src/main imports no electron module at the top level
  • [ ] Main-process services accept their Node dependencies as constructor args
  • [ ] Renderer tests stub window.electron in a global setup file
  • [ ] A contract test enumerates every method on the preload bridge and asserts raw ipcRenderer is not exposed
  • [ ] IPC channel names live in a single shared constants module
  • [ ] E2E launches the built output, not source
  • [ ] Every E2E worker gets a fresh --user-data-dir
  • [ ] Native dialogs and auto-updater are behind interfaces with test doubles
  • [ ] CI runs unit + contract on every PR, E2E on every push to main
  • [ ] Playwright and Electron major versions are pinned together

Testing Electron well is less about choosing a framework and more about drawing the process boundaries clearly. Once your main logic is plain Node, your renderer is a plain web app, and your preload is a tiny testable contract, the actual test files are almost boring — which is exactly what you want.

Top comments (0)