Testing Electron Apps with Playwright: End-to-End Desktop Automation

Testing Electron Apps with Playwright: End-to-End Desktop Automation

Electron applications present a unique testing challenge: they're web apps running in a desktop shell, combining Chromium's rendering engine with Node.js. Playwright, Microsoft's browser automation library, has native Electron support that lets you drive Electron apps the same way you'd drive a web browser — with all the power of Playwright's API.

Why Playwright for Electron?

Before Playwright added Electron support, developers used Spectron (now deprecated) or resorted to keyboard simulation tools. Playwright's approach is different: it connects to Electron's debugger protocol directly, giving you:

  • The same API as Playwright's web tests
  • Access to browser windows, pages, and contexts
  • Ability to intercept network requests
  • Screenshots and video capture
  • Reliable waiting mechanisms

Installation

npm install --save-dev playwright @playwright/test
# Or just the Electron-specific package
npm install --save-dev playwright

No separate Electron integration package needed — Playwright includes Electron support in playwright/test.

Launching Your Electron App

// tests/electron/electron.spec.js
const { test, expect, _electron: electron } = require('@playwright/test');

test('launch app and verify window', async () => {
  const electronApp = await electron.launch({
    args: ['main.js'],  // your Electron main process file
  });

  // Wait for the first window
  const page = await electronApp.firstWindow();

  // Verify the window title
  const title = await page.title();
  expect(title).toBe('My Electron App');

  await electronApp.close();
});

Organizing Tests with Fixtures

A fixture ensures you get a fresh app instance per test without repeating launch code:

// tests/fixtures.js
const { test: base, expect, _electron: electron } = require('@playwright/test');
const path = require('path');

const test = base.extend({
  electronApp: async ({}, use) => {
    const app = await electron.launch({
      args: [path.join(__dirname, '../main.js')],
      env: {
        NODE_ENV: 'test',
        ...process.env
      }
    });
    await use(app);
    await app.close();
  },

  window: async ({ electronApp }, use) => {
    const page = await electronApp.firstWindow();
    await page.waitForLoadState('domcontentloaded');
    await use(page);
  }
});

module.exports = { test, expect };

Use the fixture in tests:

// tests/app.spec.js
const { test, expect } = require('./fixtures');

test('main window loads correctly', async ({ window }) => {
  await expect(window).toHaveTitle('My Electron App');
  await expect(window.locator('h1')).toContainText('Welcome');
});

test('shows version number', async ({ window }) => {
  const version = await window.locator('[data-testid="version"]').textContent();
  expect(version).toMatch(/^\d+\.\d+\.\d+$/);
});

Interacting with Windows

Electron apps often have multiple windows. Playwright handles them through events:

test('opens settings window', async ({ electronApp, window }) => {
  // Listen for new window BEFORE triggering the action
  const [settingsWindow] = await Promise.all([
    electronApp.waitForEvent('window'),
    window.click('[data-testid="settings-button"]')
  ]);

  await settingsWindow.waitForLoadState('domcontentloaded');
  await expect(settingsWindow).toHaveTitle('Settings');
  await expect(settingsWindow.locator('.settings-panel')).toBeVisible();
});

Testing Native Menus

Electron menus can be triggered via keyboard shortcuts:

test('File > New creates a new document', async ({ electronApp, window }) => {
  // Trigger menu via keyboard shortcut
  await window.keyboard.press('Control+N');  // or Meta+N on Mac

  // Verify the result
  await expect(window.locator('.document-count')).toHaveText('1 document');
});

Or use Electron's API directly through electronApp.evaluate():

test('triggers menu item programmatically', async ({ electronApp }) => {
  // Run code in the Electron main process
  await electronApp.evaluate(async ({ app, Menu }) => {
    const menu = Menu.getApplicationMenu();
    const fileMenu = menu.items.find(item => item.label === 'File');
    const newItem = fileMenu.submenu.items.find(item => item.label === 'New');
    newItem.click();
  });
});

Testing Dialog Boxes

File dialogs and message boxes require interception:

test('save dialog appears on Ctrl+S', async ({ electronApp, window }) => {
  // Intercept the dialog before triggering
  electronApp.on('dialog', async dialog => {
    // dialog is a Playwright Dialog object
    console.log(`Dialog type: ${dialog.type()}`);
    await dialog.accept('/tmp/test-file.txt');
  });

  // Make content dirty so save triggers a dialog
  await window.type('.editor', 'Hello, world!');
  await window.keyboard.press('Control+S');

  await expect(window.locator('.status-bar')).toHaveText('Saved');
});

For showOpenDialog and showSaveDialog (Electron's native file pickers), mock them in the main process:

test('opens file through dialog', async ({ electronApp, window }) => {
  // Mock the dialog in the main process
  await electronApp.evaluate(async ({ dialog }) => {
    dialog.showOpenDialog = async () => ({
      canceled: false,
      filePaths: ['/tmp/test-document.txt']
    });
  });

  await window.click('[data-testid="open-file"]');
  await expect(window.locator('.file-name')).toHaveText('test-document.txt');
});

Accessing Electron APIs

electronApp.evaluate() runs code in the Electron main process, giving you access to any Electron API:

test('reads app version from package.json', async ({ electronApp }) => {
  const version = await electronApp.evaluate(async ({ app }) => {
    return app.getVersion();
  });
  expect(version).toMatch(/^\d+\.\d+\.\d+$/);
});

test('app data is stored in correct path', async ({ electronApp }) => {
  const dataPath = await electronApp.evaluate(async ({ app }) => {
    return app.getPath('userData');
  });
  expect(dataPath).toContain('my-electron-app');
});

For renderer process code, use page.evaluate():

test('localStorage is cleared on logout', async ({ window }) => {
  // Set some data
  await window.evaluate(() => localStorage.setItem('token', 'abc123'));

  await window.click('[data-testid="logout"]');

  const token = await window.evaluate(() => localStorage.getItem('token'));
  expect(token).toBeNull();
});

Running in CI

Electron requires a display server in headless Linux CI environments:

# .github/workflows/test.yml
- name: Install dependencies
  run: npm ci

- name: Install Playwright browsers
  run: npx playwright install --with-deps

- name: Run Electron tests
  run: npx playwright test
  env:
    DISPLAY: ':0'  # For Linux with Xvfb

# Or use the Xvfb action
- uses: GabrielBB/xvfb-action@v1
  with:
    run: npx playwright test

Playwright Configuration for Electron

// playwright.config.js
module.exports = {
  testDir: './tests',
  timeout: 30000,
  use: {
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    trace: 'on-first-retry'
  },
  projects: [
    {
      name: 'electron',
      testMatch: '**/electron/**/*.spec.js'
    }
  ]
};

Debugging Tests

# Run with Playwright Inspector
PWDEBUG=1 npx playwright <span class="hljs-built_in">test

<span class="hljs-comment"># Show browser (Electron window)
npx playwright <span class="hljs-built_in">test --headed

<span class="hljs-comment"># Record and replay
npx playwright codegen  <span class="hljs-comment"># Not directly for Electron, but useful for web portions

Continuous Monitoring with HelpMeTest

Playwright handles automated test runs in CI. For continuous monitoring of your Electron app's update behavior, crash reporting, and backend API dependencies, HelpMeTest monitors your server-side endpoints 24/7 — ensuring the services your Electron app connects to are healthy before users report problems.

Summary

Playwright is the best current option for Electron end-to-end testing:

  • electron.launch() — starts your app with test configuration
  • Fixtures — share app instances cleanly between tests
  • electronApp.evaluate() — access main process Electron APIs
  • page.evaluate() — access renderer process APIs
  • Dialog interception — handle native file pickers and alerts
  • CI-friendly — works with Xvfb on headless Linux

The web-first mental model transfers directly: elements, locators, clicks, and assertions work the same way in Electron as in a browser.

Read more