When to use: When your application is an Electron desktop app and you need end-to-end tests covering the renderer process, main process, IPC communication, native dialogs, system tray, and multi-window workflows. Prerequisites: core/configuration.md, core/fixtures-and-hooks.md
import { _electron as electron } from 'playwright';
// Launch the Electron app
const app = await electron.launch({ args: ['./main.js'] });
// Get the first window (renderer process)
const window = await app.firstWindow();
// Access the main process for evaluation
const appPath = await app.evaluate(async ({ app }) => {
return app.getPath('userData');
});
// Close the app
await app.close();Use when: Starting to test an Electron app with Playwright for the first time. Avoid when: Your app is a web app, not an Electron app.
TypeScript
import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test';
let app: ElectronApplication;
let window: Page;
test.beforeAll(async () => {
// Launch the Electron app from your project directory
app = await electron.launch({
args: ['./dist/main.js'],
env: {
...process.env,
NODE_ENV: 'test',
},
});
// Wait for the first BrowserWindow to open
window = await app.firstWindow();
// Optional: wait for the app to be fully loaded
await window.waitForLoadState('domcontentloaded');
});
test.afterAll(async () => {
await app.close();
});
test('app window has correct title', async () => {
const title = await window.title();
expect(title).toBe('My Electron App');
});
test('main page renders', async () => {
await expect(window.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});JavaScript
const { test, expect, _electron: electron } = require('@playwright/test');
let app;
let window;
test.beforeAll(async () => {
app = await electron.launch({
args: ['./dist/main.js'],
env: {
...process.env,
NODE_ENV: 'test',
},
});
window = await app.firstWindow();
await window.waitForLoadState('domcontentloaded');
});
test.afterAll(async () => {
await app.close();
});
test('app window has correct title', async () => {
const title = await window.title();
expect(title).toBe('My Electron App');
});
test('main page renders', async () => {
await expect(window.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});Use when: You want isolated, reusable Electron app instances across test files. Avoid when: All tests can share a single app instance (rare in practice).
TypeScript
// fixtures.ts
import { test as base, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test';
type ElectronFixtures = {
electronApp: ElectronApplication;
window: Page;
};
export const test = base.extend<ElectronFixtures>({
electronApp: async ({}, use) => {
const app = await electron.launch({
args: ['./dist/main.js'],
env: { ...process.env, NODE_ENV: 'test' },
});
await use(app);
await app.close();
},
window: async ({ electronApp }, use) => {
const window = await electronApp.firstWindow();
await window.waitForLoadState('domcontentloaded');
await use(window);
},
});
export { expect };// app.spec.ts
import { test, expect } from './fixtures';
test('navigate to settings', async ({ window }) => {
await window.getByRole('link', { name: 'Settings' }).click();
await expect(window.getByRole('heading', { name: 'Settings' })).toBeVisible();
});JavaScript
// fixtures.js
const { test: base, expect, _electron: electron } = require('@playwright/test');
const test = base.extend({
electronApp: async ({}, use) => {
const app = await electron.launch({
args: ['./dist/main.js'],
env: { ...process.env, NODE_ENV: 'test' },
});
await use(app);
await app.close();
},
window: async ({ electronApp }, use) => {
const window = await electronApp.firstWindow();
await window.waitForLoadState('domcontentloaded');
await use(window);
},
});
module.exports = { test, expect };Use when: You need to read Electron app state, check paths, get app version, or verify main process behavior. Avoid when: Everything you need is in the renderer (UI). Prefer testing through the UI.
app.evaluate() runs code in the main process with access to all Electron APIs.
TypeScript
import { test, expect } from './fixtures';
test('verify app version and paths', async ({ electronApp }) => {
// Evaluate in the main process — receives the Electron module
const appInfo = await electronApp.evaluate(async ({ app }) => {
return {
version: app.getVersion(),
name: app.getName(),
userData: app.getPath('userData'),
locale: app.getLocale(),
isPackaged: app.isPackaged,
};
});
expect(appInfo.version).toMatch(/^\d+\.\d+\.\d+$/);
expect(appInfo.name).toBe('my-electron-app');
expect(appInfo.userData).toBeTruthy();
expect(appInfo.isPackaged).toBe(false); // false during development
});
test('main process environment variables are set', async ({ electronApp }) => {
const nodeEnv = await electronApp.evaluate(async () => {
return process.env.NODE_ENV;
});
expect(nodeEnv).toBe('test');
});JavaScript
const { test, expect } = require('./fixtures');
test('verify app version and paths', async ({ electronApp }) => {
const appInfo = await electronApp.evaluate(async ({ app }) => {
return {
version: app.getVersion(),
name: app.getName(),
userData: app.getPath('userData'),
isPackaged: app.isPackaged,
};
});
expect(appInfo.version).toMatch(/^\d+\.\d+\.\d+$/);
expect(appInfo.name).toBe('my-electron-app');
});Use when: Your app uses ipcMain / ipcRenderer for communication between the main and renderer processes.
Avoid when: IPC is an implementation detail and the behavior is fully testable through the UI.
TypeScript
import { test, expect } from './fixtures';
test('renderer sends IPC message and gets response', async ({ electronApp, window }) => {
// Trigger an IPC call from the renderer
const result = await window.evaluate(async () => {
// Assumes your preload script exposes ipcRenderer via contextBridge
return await (window as any).electronAPI.getSystemInfo();
});
expect(result).toHaveProperty('platform');
expect(result).toHaveProperty('arch');
expect(result.platform).toBeTruthy();
});
test('main process handles IPC file-read request', async ({ electronApp, window }) => {
// Set up a listener in the main process first
await electronApp.evaluate(async ({ ipcMain }) => {
ipcMain.handle('test-ping', async () => {
return { pong: true, timestamp: Date.now() };
});
});
// Send from renderer
const response = await window.evaluate(async () => {
return await (window as any).electronAPI.invoke('test-ping');
});
expect(response.pong).toBe(true);
expect(response.timestamp).toBeGreaterThan(0);
});
test('IPC event triggers UI update', async ({ window }) => {
// Simulate the main process sending an event to the renderer
await window.evaluate(() => {
// Trigger a custom event that the app listens for
window.dispatchEvent(new CustomEvent('app:notification', {
detail: { message: 'Update available', version: '2.0.0' },
}));
});
await expect(window.getByText('Update available')).toBeVisible();
await expect(window.getByText('Version 2.0.0')).toBeVisible();
});JavaScript
const { test, expect } = require('./fixtures');
test('renderer sends IPC message and gets response', async ({ electronApp, window }) => {
const result = await window.evaluate(async () => {
return await window.electronAPI.getSystemInfo();
});
expect(result).toHaveProperty('platform');
expect(result).toHaveProperty('arch');
});
test('IPC event triggers UI update', async ({ window }) => {
await window.evaluate(() => {
window.dispatchEvent(new CustomEvent('app:notification', {
detail: { message: 'Update available', version: '2.0.0' },
}));
});
await expect(window.getByText('Update available')).toBeVisible();
});Use when: Your app uses Electron's dialog.showOpenDialog, dialog.showSaveDialog, or similar native file dialogs.
Avoid when: File selection is handled by a web input (<input type="file">). Use standard Playwright file chooser for that.
Native dialogs cannot be interacted with directly. Mock them in the main process.
TypeScript
import { test, expect } from './fixtures';
test('open file dialog and load a document', async ({ electronApp, window }) => {
// Mock the dialog to return a specific file path
await electronApp.evaluate(async ({ dialog }) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: ['/tmp/test-document.txt'],
});
});
// Click the "Open File" button in the renderer
await window.getByRole('button', { name: 'Open File' }).click();
// Verify the app loaded the file
await expect(window.getByTestId('file-name')).toHaveText('test-document.txt');
});
test('save file dialog returns selected path', async ({ electronApp, window }) => {
await electronApp.evaluate(async ({ dialog }) => {
dialog.showSaveDialog = async () => ({
canceled: false,
filePath: '/tmp/exported-report.pdf',
});
});
await window.getByRole('button', { name: 'Export PDF' }).click();
await expect(window.getByText('Saved to /tmp/exported-report.pdf')).toBeVisible();
});
test('handle canceled file dialog', async ({ electronApp, window }) => {
await electronApp.evaluate(async ({ dialog }) => {
dialog.showOpenDialog = async () => ({
canceled: true,
filePaths: [],
});
});
await window.getByRole('button', { name: 'Open File' }).click();
// App should not crash or change state
await expect(window.getByTestId('file-name')).toHaveText('No file selected');
});JavaScript
const { test, expect } = require('./fixtures');
test('open file dialog and load a document', async ({ electronApp, window }) => {
await electronApp.evaluate(async ({ dialog }) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: ['/tmp/test-document.txt'],
});
});
await window.getByRole('button', { name: 'Open File' }).click();
await expect(window.getByTestId('file-name')).toHaveText('test-document.txt');
});
test('handle canceled file dialog', async ({ electronApp, window }) => {
await electronApp.evaluate(async ({ dialog }) => {
dialog.showOpenDialog = async () => ({
canceled: true,
filePaths: [],
});
});
await window.getByRole('button', { name: 'Open File' }).click();
await expect(window.getByTestId('file-name')).toHaveText('No file selected');
});Use when: Your app has a system tray icon with context menus or status indicators. Avoid when: Your app has no tray functionality.
Playwright cannot directly click system tray icons. Test the tray logic by evaluating in the main process.
TypeScript
import { test, expect } from './fixtures';
test('tray icon is created on app launch', async ({ electronApp }) => {
const hasTray = await electronApp.evaluate(async ({ BrowserWindow }) => {
// Access the tray via a reference your app stores
const { tray } = require('./tray-manager');
return tray !== null && !tray.isDestroyed();
});
expect(hasTray).toBe(true);
});
test('tray tooltip shows unread count', async ({ electronApp }) => {
const tooltip = await electronApp.evaluate(async () => {
const { tray } = require('./tray-manager');
return tray.getToolTip();
});
expect(tooltip).toMatch(/\d+ unread messages?/);
});
test('clicking tray "Show" menu item opens the window', async ({ electronApp }) => {
// Simulate clicking a tray menu item by invoking its callback
await electronApp.evaluate(async ({ BrowserWindow }) => {
const { trayMenu } = require('./tray-manager');
// Find the "Show" menu item and invoke its click handler
const showItem = trayMenu.items.find((item: any) => item.label === 'Show');
if (showItem && showItem.click) {
showItem.click();
}
});
// The main window should now be visible
const window = await electronApp.firstWindow();
const isVisible = await window.evaluate(() => {
return document.visibilityState === 'visible';
});
expect(isVisible).toBe(true);
});JavaScript
const { test, expect } = require('./fixtures');
test('tray icon is created on app launch', async ({ electronApp }) => {
const hasTray = await electronApp.evaluate(async () => {
const { tray } = require('./tray-manager');
return tray !== null && !tray.isDestroyed();
});
expect(hasTray).toBe(true);
});Use when: Your Electron app opens multiple windows (preferences, about, detached panels). Avoid when: Your app uses a single window.
TypeScript
import { test, expect } from './fixtures';
test('open and interact with preferences window', async ({ electronApp, window }) => {
// Click the menu item or button that opens the preferences window
await window.getByRole('menuitem', { name: 'Preferences' }).click();
// Wait for the new window to appear
const prefsWindow = await electronApp.waitForEvent('window');
await prefsWindow.waitForLoadState('domcontentloaded');
// Interact with the preferences window
await prefsWindow.getByLabel('Theme').selectOption('dark');
await prefsWindow.getByRole('button', { name: 'Save' }).click();
// Verify the main window reflects the change
await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark');
// Close the preferences window
await prefsWindow.close();
});
test('get all open windows', async ({ electronApp, window }) => {
// Open a second window
await window.getByRole('button', { name: 'New Window' }).click();
// Get all windows
const allWindows = electronApp.windows();
expect(allWindows.length).toBe(2);
// Find the new window (not the main one)
const newWindow = allWindows.find((w) => w !== window)!;
await expect(newWindow.getByRole('heading')).toBeVisible();
});JavaScript
const { test, expect } = require('./fixtures');
test('open and interact with preferences window', async ({ electronApp, window }) => {
await window.getByRole('menuitem', { name: 'Preferences' }).click();
const prefsWindow = await electronApp.waitForEvent('window');
await prefsWindow.waitForLoadState('domcontentloaded');
await prefsWindow.getByLabel('Theme').selectOption('dark');
await prefsWindow.getByRole('button', { name: 'Save' }).click();
await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark');
await prefsWindow.close();
});Use when: You want to test the production build of your Electron app (after electron-builder, electron-forge, etc.).
Avoid when: Development mode testing is sufficient for your CI pipeline.
TypeScript
import { test, expect, _electron as electron } from '@playwright/test';
import path from 'path';
test('packaged app launches and works', async () => {
// Path to the packaged app executable
const appPath = process.platform === 'darwin'
? path.join(__dirname, '../dist/mac/MyApp.app/Contents/MacOS/MyApp')
: process.platform === 'win32'
? path.join(__dirname, '../dist/win-unpacked/MyApp.exe')
: path.join(__dirname, '../dist/linux-unpacked/my-app');
const app = await electron.launch({
executablePath: appPath,
});
const window = await app.firstWindow();
await window.waitForLoadState('domcontentloaded');
// Verify the packaged app works correctly
const title = await window.title();
expect(title).toBe('My Electron App');
await expect(window.getByRole('heading', { name: 'Welcome' })).toBeVisible();
// Verify it reports as packaged
const isPackaged = await app.evaluate(async ({ app }) => app.isPackaged);
expect(isPackaged).toBe(true);
await app.close();
});JavaScript
const { test, expect, _electron: electron } = require('@playwright/test');
const path = require('path');
test('packaged app launches and works', async () => {
const appPath = process.platform === 'darwin'
? path.join(__dirname, '../dist/mac/MyApp.app/Contents/MacOS/MyApp')
: process.platform === 'win32'
? path.join(__dirname, '../dist/win-unpacked/MyApp.exe')
: path.join(__dirname, '../dist/linux-unpacked/my-app');
const app = await electron.launch({
executablePath: appPath,
});
const window = await app.firstWindow();
await window.waitForLoadState('domcontentloaded');
const title = await window.title();
expect(title).toBe('My Electron App');
await expect(window.getByRole('heading', { name: 'Welcome' })).toBeVisible();
await app.close();
});| Scenario | Approach | Why |
|---|---|---|
| Launch Electron app for testing | _electron.launch({ args: ['./main.js'] }) |
Playwright's built-in Electron support |
| Get the main window | app.firstWindow() |
Returns the first BrowserWindow as a Playwright Page |
| Read main process state | app.evaluate(({ app }) => ...) |
Runs code in the main process with Electron APIs |
| Test IPC round-trips | window.evaluate (renderer) + app.evaluate (main) |
Cover both sides of the IPC bridge |
| Mock native file dialogs | Override dialog.showOpenDialog via app.evaluate |
Native dialogs cannot be automated directly |
| Test system tray | app.evaluate to invoke tray callbacks |
Tray icons are OS-native; not clickable via Playwright |
| Multiple windows | app.waitForEvent('window') |
Captures new BrowserWindow instances as they open |
| Test packaged builds | electron.launch({ executablePath }) |
Points to the built binary instead of source |
| Don't Do This | Problem | Do This Instead |
|---|---|---|
const { app } = require('electron') in test files |
Electron APIs are not available in Playwright's Node process | Use electronApp.evaluate(({ app }) => ...) |
| Directly import renderer code into tests | Bypasses the actual app lifecycle and IPC | Test through the UI via the window (Page) object |
Skip waitForLoadState after firstWindow() |
Window may not be fully rendered | Always await window.waitForLoadState('domcontentloaded') |
| Test tray by clicking system-level UI | Playwright cannot interact with native OS chrome | Mock tray menu callbacks via app.evaluate |
Share a single ElectronApplication across all tests without cleanup |
State leaks between tests | Use fixtures with app.close() in teardown |
Forget to close the app in afterAll |
Leaves Electron processes running, eating CI resources | Always await app.close() in teardown |
| Symptom | Cause | Fix |
|---|---|---|
electron.launch() throws "cannot find module" |
args path does not point to your main entry file |
Verify the path: args: ['./dist/main.js'] relative to the working directory |
firstWindow() times out |
App does not open a BrowserWindow in time |
Check that the app creates a window on startup; increase timeout |
app.evaluate cannot access Electron modules |
Destructuring the wrong parameter | Destructure correctly: evaluate(async ({ app, dialog, BrowserWindow }) => ...) |
| Dialog mock does not take effect | Mock applied after the dialog was already called | Set up mocks before triggering the UI action that opens the dialog |
| Second window not captured | waitForEvent('window') registered after the window opened |
Register the event listener before triggering the action that opens the window |
Tests hang after app.close() |
Child processes spawned by the app are still running | Ensure your Electron app cleans up child processes on quit |
| Packaged app test fails with path error | Executable path varies by OS and build tool | Use process.platform to compute the correct path |
window.evaluate throws context destroyed |
Window was closed or navigated during evaluation | Ensure the window is stable before evaluating |
- core/fixtures-and-hooks.md -- wrapping Electron app launch in fixtures
- core/assertions-and-waiting.md -- all standard assertions work on Electron windows
- core/iframes-and-shadow-dom.md -- Electron apps often embed web components or iframes
- core/browser-apis.md -- localStorage, IndexedDB, and other APIs work the same in Electron