# 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