Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Latest commit

 

History

History
622 lines (489 loc) · 20.7 KB

File metadata and controls

622 lines (489 loc) · 20.7 KB

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