Electron App End-to-End Testing with Playwright

Electron App End-to-End Testing with Playwright

Playwright has first-class support for Electron application testing since v1.9. Unlike Spectron (deprecated) or older Electron testing approaches, Playwright connects to Electron directly — giving you access to both the renderer process (web content) and the main process (Node.js environment) in a single test.

This guide covers Electron E2E testing with Playwright: setup, testing IPC communication, native dialogs, file system operations, and CI integration.

Installation

npm install -D @playwright/test playwright
npx playwright install

playwright.config.ts for an Electron app:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  timeout: 30_000,
  retries: process.env.CI ? 2 : 0,
  use: {
    // Electron tests don't use a baseURL — they launch the app directly
  },
  projects: [
    {
      name: 'electron',
      testMatch: '**/*.electron.spec.ts',
    },
  ],
});

Launching the Electron App in Tests

// e2e/fixtures.ts
import { test as base, _electron as electron } from '@playwright/test';
import type { ElectronApplication, Page } from '@playwright/test';
import path from 'path';

type ElectronFixtures = {
  electronApp: ElectronApplication;
  window: Page;
};

export const test = base.extend<ElectronFixtures>({
  electronApp: async ({}, use) => {
    const app = await electron.launch({
      args: [path.join(__dirname, '../main.js')],
      env: {
        ...process.env,
        NODE_ENV: 'test',
        ELECTRON_IS_DEV: '0',
      },
    });
    
    await use(app);
    
    await app.close();
  },
  
  window: async ({ electronApp }, use) => {
    // Wait for the first browser window to be created
    const window = await electronApp.firstWindow();
    
    // Wait for the app to be fully loaded
    await window.waitForLoadState('domcontentloaded');
    
    await use(window);
  },
});

export { expect } from '@playwright/test';

Basic App Testing

// e2e/app.electron.spec.ts
import { test, expect } from './fixtures';

test.describe('Electron application', () => {
  test('launches and shows main window', async ({ window }) => {
    const title = await window.title();
    expect(title).toContain('My App');
  });

  test('window has correct dimensions', async ({ electronApp }) => {
    const window = await electronApp.firstWindow();
    
    const size = await electronApp.evaluate(({ BrowserWindow }) => {
      const win = BrowserWindow.getAllWindows()[0];
      return { width: win.getSize()[0], height: win.getSize()[1] };
    });
    
    expect(size.width).toBeGreaterThan(800);
    expect(size.height).toBeGreaterThan(600);
  });

  test('renders main application content', async ({ window }) => {
    await expect(window.locator('[data-testid="app-root"]')).toBeVisible();
    await expect(window.locator('nav')).toBeVisible();
  });

  test('navigation between sections works', async ({ window }) => {
    await window.click('[data-testid="settings-nav"]');
    await expect(window.locator('[data-testid="settings-panel"]')).toBeVisible();
    
    await window.click('[data-testid="home-nav"]');
    await expect(window.locator('[data-testid="home-panel"]')).toBeVisible();
  });
});

Testing IPC Communication

IPC (Inter-Process Communication) is the core of Electron apps. Test both directions:

// e2e/ipc.electron.spec.ts
import { test, expect } from './fixtures';

test.describe('IPC communication', () => {
  test('renderer can invoke main process handlers', async ({ electronApp, window }) => {
    // Set up listener in main process before renderer calls
    await electronApp.evaluate(({ ipcMain }) => {
      ipcMain.handle('get-app-version', () => '2.1.0');
    });
    
    // Call from renderer and verify response
    const version = await window.evaluate(async () => {
      return await window.electron.ipcRenderer.invoke('get-app-version');
    });
    
    expect(version).toBe('2.1.0');
  });

  test('main process can send events to renderer', async ({ electronApp, window }) => {
    // Set up a listener in the renderer
    const messagePromise = window.evaluate(() => {
      return new Promise<string>((resolve) => {
        window.electron.ipcRenderer.once('notification', (_, message) => {
          resolve(message);
        });
      });
    });
    
    // Send from main process
    await electronApp.evaluate(({ BrowserWindow }) => {
      BrowserWindow.getAllWindows()[0].webContents.send('notification', 'Update available');
    });
    
    const message = await messagePromise;
    expect(message).toBe('Update available');
  });

  test('handles IPC errors gracefully', async ({ electronApp, window }) => {
    await electronApp.evaluate(({ ipcMain }) => {
      ipcMain.handle('failing-operation', () => {
        throw new Error('Operation failed in main process');
      });
    });
    
    const result = await window.evaluate(async () => {
      try {
        await window.electron.ipcRenderer.invoke('failing-operation');
        return { success: true };
      } catch (e) {
        return { success: false, error: (e as Error).message };
      }
    });
    
    expect(result.success).toBe(false);
    expect(result.error).toContain('Operation failed');
  });
});

Testing File System Operations

Electron apps frequently read and write files. Test via IPC:

// e2e/file-operations.electron.spec.ts
import { test, expect } from './fixtures';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';

test.describe('File operations', () => {
  let tempDir: string;

  test.beforeEach(async () => {
    tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'electron-test-'));
  });

  test.afterEach(async () => {
    await fs.rm(tempDir, { recursive: true, force: true });
  });

  test('saves file to disk via save dialog', async ({ electronApp, window }) => {
    const testFilePath = path.join(tempDir, 'test-output.txt');
    
    // Mock the dialog to return our test path
    await electronApp.evaluate(({ dialog }, filePath) => {
      dialog.showSaveDialogSync = () => filePath;
    }, testFilePath);
    
    // Fill in content and trigger save
    await window.fill('[data-testid="content-editor"]', 'Hello, test!');
    await window.click('[data-testid="save-file-btn"]');
    
    // Verify file was written
    const content = await fs.readFile(testFilePath, 'utf-8');
    expect(content).toBe('Hello, test!');
  });

  test('loads file from disk via open dialog', async ({ electronApp, window }) => {
    const testFilePath = path.join(tempDir, 'test-input.txt');
    await fs.writeFile(testFilePath, 'File content to load');
    
    // Mock the dialog
    await electronApp.evaluate(({ dialog }, filePath) => {
      dialog.showOpenDialogSync = () => [filePath];
    }, testFilePath);
    
    await window.click('[data-testid="open-file-btn"]');
    
    await expect(window.locator('[data-testid="content-editor"]')).toHaveValue(
      'File content to load'
    );
  });
});

Testing Native Dialogs

// e2e/dialogs.electron.spec.ts
import { test, expect } from './fixtures';

test.describe('Native dialogs', () => {
  test('confirm dialog on delete shows warning', async ({ electronApp, window }) => {
    // Mock dialog to return "cancel" to prevent actual deletion
    await electronApp.evaluate(({ dialog }) => {
      dialog.showMessageBoxSync = () => 1; // 1 = Cancel button index
    });
    
    await window.click('[data-testid="delete-item-btn"]');
    
    // Item should still exist (user cancelled)
    await expect(window.locator('[data-testid="item-to-delete"]')).toBeVisible();
  });

  test('confirm dialog on delete — confirm removes item', async ({ electronApp, window }) => {
    // Mock dialog to return "OK"
    await electronApp.evaluate(({ dialog }) => {
      dialog.showMessageBoxSync = () => 0; // 0 = OK/Yes button index
    });
    
    await window.click('[data-testid="delete-item-btn"]');
    
    // Item should be removed
    await expect(window.locator('[data-testid="item-to-delete"]')).not.toBeVisible();
  });
});

Testing Application Settings Persistence

// e2e/settings.electron.spec.ts
import { test, expect } from './fixtures';

test.describe('Settings persistence', () => {
  test('theme setting persists across app restart', async ({ electronApp, window }) => {
    // Set dark theme
    await window.click('[data-testid="theme-toggle"]');
    await expect(window.locator('body')).toHaveClass(/dark-theme/);
    
    // Restart the app
    await electronApp.close();
    
    const newApp = await electron.launch({ args: ['main.js'] });
    const newWindow = await newApp.firstWindow();
    await newWindow.waitForLoadState('domcontentloaded');
    
    // Theme should still be dark
    await expect(newWindow.locator('body')).toHaveClass(/dark-theme/);
    
    await newApp.close();
  });
});

CI Integration

name: Electron E2E Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Playwright
        run: npx playwright install --with-deps
      
      # Linux requires virtual display for Electron
      - name: Start Xvfb (Linux only)
        if: runner.os == 'Linux'
        run: Xvfb :99 -screen 0 1280x720x24 &
        env:
          DISPLAY: ':99'
      
      - name: Build app
        run: npm run build
      
      - name: Run E2E tests
        run: npx playwright test
        env:
          DISPLAY: ':99'

Summary

Electron E2E testing with Playwright:

  • Use electron.launch() from @playwright/test — direct Electron support, no Spectron needed
  • Access main process via electronApp.evaluate() — test Electron APIs, dialogs, BrowserWindow
  • Test IPC both ways — renderer invokes main, main sends to renderer
  • Mock native dialogs by replacing dialog.showSaveDialogSync etc. in the main process
  • Run on all platforms in CI — Linux requires Xvfb for a virtual display
  • Test settings persistence by relaunching the app and verifying state is restored

Playwright's Electron support removes the need for third-party test harnesses. The same test API works for both web and desktop apps.

Read more