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, core/fixtures-and-hooks.md
Quick Reference
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
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()
})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
// 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 }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
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")
})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
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()
})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 (<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")
})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
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)
})Multiple Windows
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()
})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
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()
})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 -- 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