Playwright for Electron App Automation: A Complete Guide
Electron brings web technologies to the desktop, but testing Electron applications has historically been painful. Tools built for browsers don't map cleanly to desktop windows, native menus, or IPC channels. Playwright's native Electron support changes that equation dramatically. As of Playwright 1.9+, you can drive real Electron apps end-to-end without a running browser server, without faking the environment, and without wrestling with low-level CDP setups.
This guide covers everything you need to go from zero to a reliable Electron automation suite: setup, launching apps, testing UI in renderer processes, intercepting IPC messages, handling native dialogs, and running it all in CI.
Why Playwright for Electron?
Before Playwright added Electron support, the dominant tools were Spectron (now deprecated) and raw WebDriverIO with Electron-specific configurations. Both required spinning up a ChromeDriver instance that matched your Electron version exactly — a maintenance nightmare across version bumps.
Playwright solves this differently. It connects to Electron's built-in DevTools Protocol directly, so there's no external driver to version-match. You get the same Page, Locator, and expect APIs you already know from web testing, plus Electron-specific APIs for the main process.
Installation and Setup
Start with a fresh Electron project or add Playwright to an existing one:
npm install --save-dev playwright @playwright/testPlaywright's Electron integration ships inside the main playwright package — you don't need @playwright/electron or any community wrapper.
Create a playwright.config.ts at the project root:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 30000,
use: {
// No browserName needed — Electron tests use electronApp fixture
},
});Launching Your Electron App in Tests
Playwright provides _electron from the playwright package for launching Electron apps programmatically:
import { test, expect, _electron as electron } from '@playwright/test';
import { ElectronApplication, Page } from 'playwright';
import path from 'path';
let electronApp: ElectronApplication;
let page: Page;
test.beforeAll(async () => {
electronApp = await electron.launch({
args: [path.join(__dirname, '../../main.js')],
env: {
...process.env,
NODE_ENV: 'test',
},
});
// Get the first BrowserWindow
page = await electronApp.firstWindow();
// Wait for the renderer to be ready
await page.waitForLoadState('domcontentloaded');
});
test.afterAll(async () => {
await electronApp.close();
});The electron.launch() call accepts args (passed to the Electron binary), env (environment variables), and executablePath (if you want to use a custom Electron binary). The NODE_ENV: 'test' pattern is important — your main process should check this to skip auto-updater initialization, analytics, and other production-only behavior that would slow or block tests.
Testing UI in Renderer Processes
Once you have a Page object, it behaves exactly like a Playwright browser page:
test('sidebar navigation', async () => {
// Click a nav item
await page.click('[data-testid="nav-settings"]');
// Assert the settings panel is visible
await expect(page.locator('[data-testid="settings-panel"]')).toBeVisible();
// Fill a form field
await page.fill('[data-testid="username-input"]', 'testuser');
await page.click('[data-testid="save-button"]');
// Assert success feedback
await expect(page.locator('[data-testid="save-confirmation"]')).toContainText('Saved');
});Use data-testid attributes liberally in Electron apps — unlike web apps, you don't need to worry about them being visible to end users through DOM inspection, but they make your tests dramatically more stable than CSS selector chains.
Handling Multiple Windows
Real Electron apps often open multiple windows: a main window, a settings window, an about dialog. Playwright handles this cleanly:
test('opens preferences window', async () => {
// Trigger the action that opens a new window
await page.click('[data-testid="open-preferences"]');
// Wait for a new window to appear
const prefsWindow = await electronApp.waitForEvent('window');
await prefsWindow.waitForLoadState('domcontentloaded');
// Now interact with the new window
await expect(prefsWindow.locator('h1')).toContainText('Preferences');
// Get all open windows
const windows = electronApp.windows();
expect(windows).toHaveLength(2);
});The electronApp.waitForEvent('window') call returns a Promise that resolves to the new Page object when it appears. This is much cleaner than polling electronApp.windows() in a loop.
Evaluating Code in the Main Process
One of Playwright's killer features for Electron testing is electronApp.evaluate(), which lets you run code directly in the main process Node.js context:
test('app metadata is correct', async () => {
const appInfo = await electronApp.evaluate(async ({ app }) => {
return {
name: app.getName(),
version: app.getVersion(),
locale: app.getLocale(),
};
});
expect(appInfo.name).toBe('MyApp');
expect(appInfo.version).toMatch(/^\d+\.\d+\.\d+$/);
});
test('window is not maximized by default', async () => {
const isMaximized = await electronApp.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
return win.isMaximized();
});
expect(isMaximized).toBe(false);
});The callback receives the Electron app object, BrowserWindow, ipcMain, and any other module you'd normally require in the main process. This lets you assert against window state, app configuration, and any main-process data without having to expose it through the renderer.
Intercepting and Mocking IPC Messages
Testing IPC communication end-to-end requires triggering renderer-to-main messages and asserting the results. Here's a pattern for testing an IPC call that fetches data from the filesystem:
test('loads recent files via IPC', async () => {
// Mock the main process handler to return predictable data
await electronApp.evaluate(({ ipcMain }) => {
// Remove any existing handler
ipcMain.removeAllListeners('get-recent-files');
// Register a test handler
ipcMain.handle('get-recent-files', async () => {
return [
{ name: 'document.txt', path: '/home/user/document.txt', modified: 1700000000000 },
{ name: 'notes.md', path: '/home/user/notes.md', modified: 1699000000000 },
];
});
});
// Trigger the renderer to request recent files
await page.click('[data-testid="file-menu"]');
await page.click('[data-testid="recent-files"]');
// Assert the files appear in the UI
const items = page.locator('[data-testid="recent-file-item"]');
await expect(items).toHaveCount(2);
await expect(items.first()).toContainText('document.txt');
});This approach keeps your tests deterministic — you're not dependent on the user's actual filesystem state or any external service.
Native Dialog Handling
File open/save dialogs and message boxes are native OS dialogs that can't be interacted with through DOM APIs. Playwright solves this by letting you intercept them at the Electron level:
test('exports data to file', async () => {
// Mock showSaveDialog before triggering the export
await electronApp.evaluate(({ dialog }) => {
// @ts-ignore - override for testing
dialog.showSaveDialog = async () => ({
canceled: false,
filePath: '/tmp/test-export.json',
});
});
await page.click('[data-testid="export-button"]');
// Assert the UI shows export success
await expect(page.locator('[data-testid="export-status"]')).toContainText('Export complete');
// Optionally verify the file was written
const fileExists = await electronApp.evaluate(async () => {
const fs = require('fs');
return fs.existsSync('/tmp/test-export.json');
});
expect(fileExists).toBe(true);
});Taking Screenshots and Visual Regression
Playwright's screenshot capabilities work in Electron exactly as they do for web:
test('main window visual snapshot', async () => {
await expect(page).toHaveScreenshot('main-window.png', {
fullPage: false,
threshold: 0.1, // 10% pixel difference tolerance
});
});Screenshots are saved to a __screenshots__ directory and compared on subsequent runs. This is powerful for catching unintended UI regressions, especially across Electron version upgrades.
Structuring Your Test Suite
For larger apps, organize tests by feature area rather than by component. Each test file should set up its own electronApp instance to ensure full isolation:
// tests/e2e/editor.spec.ts
import { test, expect, _electron as electron } from '@playwright/test';
test.describe('Text Editor', () => {
// beforeAll/afterAll per describe block
test('creates a new document', async ({ }) => { /* ... */ });
test('saves with keyboard shortcut', async ({ }) => { /* ... */ });
test('shows unsaved changes indicator', async ({ }) => { /* ... */ });
});Use test.describe.serial() for tests that must run in sequence (like a file creation flow where later tests depend on the file existing). For independent tests, the default parallel execution is fine.
Performance Tips
Launching Electron is slow — typically 2-5 seconds per test file. Use test.beforeAll (not test.beforeEach) to share one app instance across all tests in a file. Only use beforeEach when a test genuinely requires a fresh app state.
For state reset between tests without relaunching:
test.beforeEach(async () => {
// Reset app state via IPC instead of restarting
await electronApp.evaluate(({ ipcMain }) => {
// Call an internal reset handler your app exposes in test mode
});
// Or navigate to a clean state in the renderer
await page.evaluate(() => window.__resetAppState?.());
});Running in CI
Electron apps require a display server in Linux CI environments. Add xvfb-run to your test command:
# .github/workflows/test.yml
- name: Run Playwright Electron tests
run: xvfb-run --auto-servernum npx playwright testOn macOS and Windows GitHub Actions runners, no display configuration is needed. See the dedicated CI post in this series for a full cross-platform matrix setup.
What Playwright Electron Testing Covers — and What It Doesn't
Playwright handles UI automation, IPC testing, and window management exceptionally well. It does not help you test native OS integrations like system notifications, global keyboard shortcuts registered via globalShortcut, or deep OS-level file associations. For those, you typically need platform-specific test utilities or manual verification.
For everything that lives in your windows, though, Playwright gives you a robust, maintainable automation layer that catches regressions before they reach your users.
Conclusion
Playwright's Electron support has matured to the point where there's no compelling reason to use older tools. The unified API, direct CDP connection, and main-process evaluation capabilities make it possible to write tests that cover the full stack of an Electron application — from UI interactions down to main-process state — without brittle driver configurations or deprecated dependencies. Start with smoke tests covering your app's critical paths, add IPC mocking for deterministic behavior, and hook it into CI for continuous feedback.