Testing Electron Apps with Playwright: End-to-End Desktop Automation
Electron applications present a unique testing challenge: they're web apps running in a desktop shell, combining Chromium's rendering engine with Node.js. Playwright, Microsoft's browser automation library, has native Electron support that lets you drive Electron apps the same way you'd drive a web browser — with all the power of Playwright's API.
Why Playwright for Electron?
Before Playwright added Electron support, developers used Spectron (now deprecated) or resorted to keyboard simulation tools. Playwright's approach is different: it connects to Electron's debugger protocol directly, giving you:
- The same API as Playwright's web tests
- Access to browser windows, pages, and contexts
- Ability to intercept network requests
- Screenshots and video capture
- Reliable waiting mechanisms
Installation
npm install --save-dev playwright @playwright/test
# Or just the Electron-specific package
npm install --save-dev playwrightNo separate Electron integration package needed — Playwright includes Electron support in playwright/test.
Launching Your Electron App
// tests/electron/electron.spec.js
const { test, expect, _electron: electron } = require('@playwright/test');
test('launch app and verify window', async () => {
const electronApp = await electron.launch({
args: ['main.js'], // your Electron main process file
});
// Wait for the first window
const page = await electronApp.firstWindow();
// Verify the window title
const title = await page.title();
expect(title).toBe('My Electron App');
await electronApp.close();
});Organizing Tests with Fixtures
A fixture ensures you get a fresh app instance per test without repeating launch code:
// tests/fixtures.js
const { test: base, expect, _electron: electron } = require('@playwright/test');
const path = require('path');
const test = base.extend({
electronApp: async ({}, use) => {
const app = await electron.launch({
args: [path.join(__dirname, '../main.js')],
env: {
NODE_ENV: 'test',
...process.env
}
});
await use(app);
await app.close();
},
window: async ({ electronApp }, use) => {
const page = await electronApp.firstWindow();
await page.waitForLoadState('domcontentloaded');
await use(page);
}
});
module.exports = { test, expect };Use the fixture in tests:
// tests/app.spec.js
const { test, expect } = require('./fixtures');
test('main window loads correctly', async ({ window }) => {
await expect(window).toHaveTitle('My Electron App');
await expect(window.locator('h1')).toContainText('Welcome');
});
test('shows version number', async ({ window }) => {
const version = await window.locator('[data-testid="version"]').textContent();
expect(version).toMatch(/^\d+\.\d+\.\d+$/);
});Interacting with Windows
Electron apps often have multiple windows. Playwright handles them through events:
test('opens settings window', async ({ electronApp, window }) => {
// Listen for new window BEFORE triggering the action
const [settingsWindow] = await Promise.all([
electronApp.waitForEvent('window'),
window.click('[data-testid="settings-button"]')
]);
await settingsWindow.waitForLoadState('domcontentloaded');
await expect(settingsWindow).toHaveTitle('Settings');
await expect(settingsWindow.locator('.settings-panel')).toBeVisible();
});Testing Native Menus
Electron menus can be triggered via keyboard shortcuts:
test('File > New creates a new document', async ({ electronApp, window }) => {
// Trigger menu via keyboard shortcut
await window.keyboard.press('Control+N'); // or Meta+N on Mac
// Verify the result
await expect(window.locator('.document-count')).toHaveText('1 document');
});Or use Electron's API directly through electronApp.evaluate():
test('triggers menu item programmatically', async ({ electronApp }) => {
// Run code in the Electron main process
await electronApp.evaluate(async ({ app, Menu }) => {
const menu = Menu.getApplicationMenu();
const fileMenu = menu.items.find(item => item.label === 'File');
const newItem = fileMenu.submenu.items.find(item => item.label === 'New');
newItem.click();
});
});Testing Dialog Boxes
File dialogs and message boxes require interception:
test('save dialog appears on Ctrl+S', async ({ electronApp, window }) => {
// Intercept the dialog before triggering
electronApp.on('dialog', async dialog => {
// dialog is a Playwright Dialog object
console.log(`Dialog type: ${dialog.type()}`);
await dialog.accept('/tmp/test-file.txt');
});
// Make content dirty so save triggers a dialog
await window.type('.editor', 'Hello, world!');
await window.keyboard.press('Control+S');
await expect(window.locator('.status-bar')).toHaveText('Saved');
});For showOpenDialog and showSaveDialog (Electron's native file pickers), mock them in the main process:
test('opens file through dialog', async ({ electronApp, window }) => {
// Mock the dialog in the main process
await electronApp.evaluate(async ({ dialog }) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: ['/tmp/test-document.txt']
});
});
await window.click('[data-testid="open-file"]');
await expect(window.locator('.file-name')).toHaveText('test-document.txt');
});Accessing Electron APIs
electronApp.evaluate() runs code in the Electron main process, giving you access to any Electron API:
test('reads app version from package.json', async ({ electronApp }) => {
const version = await electronApp.evaluate(async ({ app }) => {
return app.getVersion();
});
expect(version).toMatch(/^\d+\.\d+\.\d+$/);
});
test('app data is stored in correct path', async ({ electronApp }) => {
const dataPath = await electronApp.evaluate(async ({ app }) => {
return app.getPath('userData');
});
expect(dataPath).toContain('my-electron-app');
});For renderer process code, use page.evaluate():
test('localStorage is cleared on logout', async ({ window }) => {
// Set some data
await window.evaluate(() => localStorage.setItem('token', 'abc123'));
await window.click('[data-testid="logout"]');
const token = await window.evaluate(() => localStorage.getItem('token'));
expect(token).toBeNull();
});Running in CI
Electron requires a display server in headless Linux CI environments:
# .github/workflows/test.yml
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Electron tests
run: npx playwright test
env:
DISPLAY: ':0' # For Linux with Xvfb
# Or use the Xvfb action
- uses: GabrielBB/xvfb-action@v1
with:
run: npx playwright testPlaywright Configuration for Electron
// playwright.config.js
module.exports = {
testDir: './tests',
timeout: 30000,
use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'on-first-retry'
},
projects: [
{
name: 'electron',
testMatch: '**/electron/**/*.spec.js'
}
]
};Debugging Tests
# Run with Playwright Inspector
PWDEBUG=1 npx playwright <span class="hljs-built_in">test
<span class="hljs-comment"># Show browser (Electron window)
npx playwright <span class="hljs-built_in">test --headed
<span class="hljs-comment"># Record and replay
npx playwright codegen <span class="hljs-comment"># Not directly for Electron, but useful for web portionsContinuous Monitoring with HelpMeTest
Playwright handles automated test runs in CI. For continuous monitoring of your Electron app's update behavior, crash reporting, and backend API dependencies, HelpMeTest monitors your server-side endpoints 24/7 — ensuring the services your Electron app connects to are healthy before users report problems.
Summary
Playwright is the best current option for Electron end-to-end testing:
electron.launch()— starts your app with test configuration- Fixtures — share app instances cleanly between tests
electronApp.evaluate()— access main process Electron APIspage.evaluate()— access renderer process APIs- Dialog interception — handle native file pickers and alerts
- CI-friendly — works with Xvfb on headless Linux
The web-first mental model transfers directly: elements, locators, clicks, and assertions work the same way in Electron as in a browser.