Electron App End-to-End Testing with Playwright
Playwright has first-class support for Electron application testing since v1.9. Unlike Spectron (deprecated) or older Electron testing approaches, Playwright connects to Electron directly — giving you access to both the renderer process (web content) and the main process (Node.js environment) in a single test.
This guide covers Electron E2E testing with Playwright: setup, testing IPC communication, native dialogs, file system operations, and CI integration.
Installation
npm install -D @playwright/test playwright
npx playwright installplaywright.config.ts for an Electron app:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
use: {
// Electron tests don't use a baseURL — they launch the app directly
},
projects: [
{
name: 'electron',
testMatch: '**/*.electron.spec.ts',
},
],
});Launching the Electron App in Tests
// e2e/fixtures.ts
import { test as base, _electron as electron } from '@playwright/test';
import type { ElectronApplication, Page } from '@playwright/test';
import path from 'path';
type ElectronFixtures = {
electronApp: ElectronApplication;
window: Page;
};
export const test = base.extend<ElectronFixtures>({
electronApp: async ({}, use) => {
const app = await electron.launch({
args: [path.join(__dirname, '../main.js')],
env: {
...process.env,
NODE_ENV: 'test',
ELECTRON_IS_DEV: '0',
},
});
await use(app);
await app.close();
},
window: async ({ electronApp }, use) => {
// Wait for the first browser window to be created
const window = await electronApp.firstWindow();
// Wait for the app to be fully loaded
await window.waitForLoadState('domcontentloaded');
await use(window);
},
});
export { expect } from '@playwright/test';Basic App Testing
// e2e/app.electron.spec.ts
import { test, expect } from './fixtures';
test.describe('Electron application', () => {
test('launches and shows main window', async ({ window }) => {
const title = await window.title();
expect(title).toContain('My App');
});
test('window has correct dimensions', async ({ electronApp }) => {
const window = await electronApp.firstWindow();
const size = await electronApp.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
return { width: win.getSize()[0], height: win.getSize()[1] };
});
expect(size.width).toBeGreaterThan(800);
expect(size.height).toBeGreaterThan(600);
});
test('renders main application content', async ({ window }) => {
await expect(window.locator('[data-testid="app-root"]')).toBeVisible();
await expect(window.locator('nav')).toBeVisible();
});
test('navigation between sections works', async ({ window }) => {
await window.click('[data-testid="settings-nav"]');
await expect(window.locator('[data-testid="settings-panel"]')).toBeVisible();
await window.click('[data-testid="home-nav"]');
await expect(window.locator('[data-testid="home-panel"]')).toBeVisible();
});
});Testing IPC Communication
IPC (Inter-Process Communication) is the core of Electron apps. Test both directions:
// e2e/ipc.electron.spec.ts
import { test, expect } from './fixtures';
test.describe('IPC communication', () => {
test('renderer can invoke main process handlers', async ({ electronApp, window }) => {
// Set up listener in main process before renderer calls
await electronApp.evaluate(({ ipcMain }) => {
ipcMain.handle('get-app-version', () => '2.1.0');
});
// Call from renderer and verify response
const version = await window.evaluate(async () => {
return await window.electron.ipcRenderer.invoke('get-app-version');
});
expect(version).toBe('2.1.0');
});
test('main process can send events to renderer', async ({ electronApp, window }) => {
// Set up a listener in the renderer
const messagePromise = window.evaluate(() => {
return new Promise<string>((resolve) => {
window.electron.ipcRenderer.once('notification', (_, message) => {
resolve(message);
});
});
});
// Send from main process
await electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].webContents.send('notification', 'Update available');
});
const message = await messagePromise;
expect(message).toBe('Update available');
});
test('handles IPC errors gracefully', async ({ electronApp, window }) => {
await electronApp.evaluate(({ ipcMain }) => {
ipcMain.handle('failing-operation', () => {
throw new Error('Operation failed in main process');
});
});
const result = await window.evaluate(async () => {
try {
await window.electron.ipcRenderer.invoke('failing-operation');
return { success: true };
} catch (e) {
return { success: false, error: (e as Error).message };
}
});
expect(result.success).toBe(false);
expect(result.error).toContain('Operation failed');
});
});Testing File System Operations
Electron apps frequently read and write files. Test via IPC:
// e2e/file-operations.electron.spec.ts
import { test, expect } from './fixtures';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
test.describe('File operations', () => {
let tempDir: string;
test.beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'electron-test-'));
});
test.afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
test('saves file to disk via save dialog', async ({ electronApp, window }) => {
const testFilePath = path.join(tempDir, 'test-output.txt');
// Mock the dialog to return our test path
await electronApp.evaluate(({ dialog }, filePath) => {
dialog.showSaveDialogSync = () => filePath;
}, testFilePath);
// Fill in content and trigger save
await window.fill('[data-testid="content-editor"]', 'Hello, test!');
await window.click('[data-testid="save-file-btn"]');
// Verify file was written
const content = await fs.readFile(testFilePath, 'utf-8');
expect(content).toBe('Hello, test!');
});
test('loads file from disk via open dialog', async ({ electronApp, window }) => {
const testFilePath = path.join(tempDir, 'test-input.txt');
await fs.writeFile(testFilePath, 'File content to load');
// Mock the dialog
await electronApp.evaluate(({ dialog }, filePath) => {
dialog.showOpenDialogSync = () => [filePath];
}, testFilePath);
await window.click('[data-testid="open-file-btn"]');
await expect(window.locator('[data-testid="content-editor"]')).toHaveValue(
'File content to load'
);
});
});Testing Native Dialogs
// e2e/dialogs.electron.spec.ts
import { test, expect } from './fixtures';
test.describe('Native dialogs', () => {
test('confirm dialog on delete shows warning', async ({ electronApp, window }) => {
// Mock dialog to return "cancel" to prevent actual deletion
await electronApp.evaluate(({ dialog }) => {
dialog.showMessageBoxSync = () => 1; // 1 = Cancel button index
});
await window.click('[data-testid="delete-item-btn"]');
// Item should still exist (user cancelled)
await expect(window.locator('[data-testid="item-to-delete"]')).toBeVisible();
});
test('confirm dialog on delete — confirm removes item', async ({ electronApp, window }) => {
// Mock dialog to return "OK"
await electronApp.evaluate(({ dialog }) => {
dialog.showMessageBoxSync = () => 0; // 0 = OK/Yes button index
});
await window.click('[data-testid="delete-item-btn"]');
// Item should be removed
await expect(window.locator('[data-testid="item-to-delete"]')).not.toBeVisible();
});
});Testing Application Settings Persistence
// e2e/settings.electron.spec.ts
import { test, expect } from './fixtures';
test.describe('Settings persistence', () => {
test('theme setting persists across app restart', async ({ electronApp, window }) => {
// Set dark theme
await window.click('[data-testid="theme-toggle"]');
await expect(window.locator('body')).toHaveClass(/dark-theme/);
// Restart the app
await electronApp.close();
const newApp = await electron.launch({ args: ['main.js'] });
const newWindow = await newApp.firstWindow();
await newWindow.waitForLoadState('domcontentloaded');
// Theme should still be dark
await expect(newWindow.locator('body')).toHaveClass(/dark-theme/);
await newApp.close();
});
});CI Integration
name: Electron E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
# Linux requires virtual display for Electron
- name: Start Xvfb (Linux only)
if: runner.os == 'Linux'
run: Xvfb :99 -screen 0 1280x720x24 &
env:
DISPLAY: ':99'
- name: Build app
run: npm run build
- name: Run E2E tests
run: npx playwright test
env:
DISPLAY: ':99'Summary
Electron E2E testing with Playwright:
- Use
electron.launch()from@playwright/test— direct Electron support, no Spectron needed - Access main process via
electronApp.evaluate()— test Electron APIs, dialogs, BrowserWindow - Test IPC both ways — renderer invokes main, main sends to renderer
- Mock native dialogs by replacing
dialog.showSaveDialogSyncetc. in the main process - Run on all platforms in CI — Linux requires Xvfb for a virtual display
- Test settings persistence by relaunching the app and verifying state is restored
Playwright's Electron support removes the need for third-party test harnesses. The same test API works for both web and desktop apps.