# Electron Testing > **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](configuration.md), [core/fixtures-and-hooks.md](fixtures-and-hooks.md) ## Quick Reference ```typescript 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(); ``` ## Patterns ### Basic Electron App Setup **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** ```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** ```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(); }); ``` ### Electron App Fixture (Recommended) **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** ```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({ 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 }; ``` ```typescript // 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** ```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 }; ``` ### Accessing the Main Process **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** ```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** ```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'); }); ``` ### Testing IPC Communication **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** ```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** ```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(); }); ``` ### File System Dialogs **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 (``). Use standard Playwright file chooser for that. Native dialogs cannot be interacted with directly. Mock them in the main process. **TypeScript** ```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** ```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'); }); ``` ### System Tray Testing **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** ```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** ```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); }); ``` ### Multiple Windows **Use when**: Your Electron app opens multiple windows (preferences, about, detached panels). **Avoid when**: Your app uses a single window. **TypeScript** ```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** ```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(); }); ``` ### Testing Packaged/Built Apps **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** ```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** ```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(); }); ``` ## Decision Guide | 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 | ## Anti-Patterns | 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 | ## Troubleshooting | 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 | ## Related - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- wrapping Electron app launch in fixtures - [core/assertions-and-waiting.md](assertions-and-waiting.md) -- all standard assertions work on Electron windows - [core/iframes-and-shadow-dom.md](iframes-and-shadow-dom.md) -- Electron apps often embed web components or iframes - [core/browser-apis.md](browser-apis.md) -- localStorage, IndexedDB, and other APIs work the same in Electron