Electron Testing with Jest: A Complete Guide to Unit Testing Desktop Apps

Electron Testing with Jest: A Complete Guide to Unit Testing Desktop Apps

Electron applications combine Node.js and Chromium into a single runtime, making them powerful but uniquely challenging to test. Unlike pure web apps or pure Node.js services, Electron apps have two processes—main and renderer—that communicate via IPC. Jest is the go-to testing framework for the Node.js side, but wiring it up correctly requires deliberate configuration.

This guide covers everything you need to unit test an Electron app with Jest: project setup, mocking Electron APIs, testing IPC handlers, isolating renderer logic, and running tests in CI.

Why Jest for Electron Unit Testing

Jest fits Electron's main process testing naturally because the main process is just Node.js. You get:

  • Fast execution — no browser launch overhead
  • Snapshot testing — great for testing menu structures and window configs
  • Built-in mocking — essential for Electron's native APIs
  • Code coverage — track what's tested across both processes

For the renderer process, Jest can run tests in a jsdom environment, letting you test React/Vue components without launching Electron itself.

Project Setup

Install Jest and the necessary Electron mocking utilities:

npm install --save-dev jest @jest/globals jest-mock-extended
npm install --save-dev @testing-library/react @testing-library/jest-dom

Configure Jest in package.json or jest.config.js:

// jest.config.js
module.exports = {
  projects: [
    {
      displayName: 'main',
      testEnvironment: 'node',
      testMatch: ['<rootDir>/src/main/**/*.test.js'],
      moduleNameMapper: {
        electron: '<rootDir>/src/__mocks__/electron.js',
      },
    },
    {
      displayName: 'renderer',
      testEnvironment: 'jsdom',
      testMatch: ['<rootDir>/src/renderer/**/*.test.js'],
      setupFilesAfterFramework: ['@testing-library/jest-dom'],
      moduleNameMapper: {
        electron: '<rootDir>/src/__mocks__/electron-renderer.js',
      },
    },
  ],
};

Mocking Electron APIs

Electron's native modules aren't available in Jest's Node environment—you must mock them. Create src/__mocks__/electron.js:

// src/__mocks__/electron.js
const { EventEmitter } = require('events');

const app = {
  getPath: jest.fn((name) => `/mock/path/${name}`),
  getVersion: jest.fn(() => '1.0.0'),
  quit: jest.fn(),
  on: jest.fn(),
  whenReady: jest.fn(() => Promise.resolve()),
};

const ipcMain = new EventEmitter();
ipcMain.handle = jest.fn();
ipcMain.removeHandler = jest.fn();

const BrowserWindow = jest.fn().mockImplementation(() => ({
  loadURL: jest.fn(),
  loadFile: jest.fn(),
  webContents: {
    send: jest.fn(),
    openDevTools: jest.fn(),
  },
  on: jest.fn(),
  once: jest.fn(),
  show: jest.fn(),
  close: jest.fn(),
  isDestroyed: jest.fn(() => false),
}));

BrowserWindow.getAllWindows = jest.fn(() => []);
BrowserWindow.getFocusedWindow = jest.fn(() => null);

const dialog = {
  showOpenDialog: jest.fn(),
  showSaveDialog: jest.fn(),
  showMessageBox: jest.fn(),
  showErrorBox: jest.fn(),
};

const shell = {
  openExternal: jest.fn(),
  openPath: jest.fn(),
};

const nativeTheme = {
  shouldUseDarkColors: false,
  on: jest.fn(),
};

module.exports = {
  app,
  ipcMain,
  BrowserWindow,
  dialog,
  shell,
  nativeTheme,
};

Testing Main Process Logic

Suppose your main process has a createWindow function:

// src/main/window.js
const { BrowserWindow, app } = require('electron');
const path = require('path');

function createWindow() {
  const win = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });

  if (process.env.NODE_ENV === 'development') {
    win.loadURL('http://localhost:3000');
  } else {
    win.loadFile(path.join(__dirname, '../renderer/index.html'));
  }

  return win;
}

module.exports = { createWindow };

Test it:

// src/main/window.test.js
const { BrowserWindow } = require('electron');
const { createWindow } = require('./window');

describe('createWindow', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('creates a BrowserWindow with correct dimensions', () => {
    createWindow();

    expect(BrowserWindow).toHaveBeenCalledWith(
      expect.objectContaining({
        width: 1200,
        height: 800,
      })
    );
  });

  it('enables context isolation and disables node integration', () => {
    createWindow();

    expect(BrowserWindow).toHaveBeenCalledWith(
      expect.objectContaining({
        webPreferences: expect.objectContaining({
          contextIsolation: true,
          nodeIntegration: false,
        }),
      })
    );
  });

  it('loads localhost in development mode', () => {
    process.env.NODE_ENV = 'development';
    const win = createWindow();

    expect(win.loadURL).toHaveBeenCalledWith('http://localhost:3000');
    expect(win.loadFile).not.toHaveBeenCalled();
  });

  it('loads index.html in production mode', () => {
    process.env.NODE_ENV = 'production';
    const win = createWindow();

    expect(win.loadFile).toHaveBeenCalled();
    expect(win.loadURL).not.toHaveBeenCalled();
  });
});

Testing IPC Handlers

IPC is where Electron's two-process architecture creates complexity. Test handlers in isolation:

// src/main/ipc-handlers.js
const { ipcMain, dialog, app } = require('electron');
const fs = require('fs/promises');
const path = require('path');

function registerHandlers() {
  ipcMain.handle('file:open', async (event, defaultPath) => {
    const result = await dialog.showOpenDialog({
      defaultPath,
      properties: ['openFile'],
      filters: [{ name: 'Text Files', extensions: ['txt', 'md'] }],
    });

    if (result.canceled || result.filePaths.length === 0) {
      return null;
    }

    const content = await fs.readFile(result.filePaths[0], 'utf-8');
    return { path: result.filePaths[0], content };
  });

  ipcMain.handle('app:getVersion', () => {
    return app.getVersion();
  });
}

module.exports = { registerHandlers };
// src/main/ipc-handlers.test.js
const { ipcMain, dialog, app } = require('electron');
const fs = require('fs/promises');
const { registerHandlers } = require('./ipc-handlers');

jest.mock('fs/promises');

describe('IPC handlers', () => {
  let handlers;

  beforeEach(() => {
    handlers = {};
    ipcMain.handle.mockImplementation((channel, handler) => {
      handlers[channel] = handler;
    });
    registerHandlers();
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('file:open', () => {
    it('returns null when dialog is canceled', async () => {
      dialog.showOpenDialog.mockResolvedValue({ canceled: true, filePaths: [] });

      const result = await handlers['file:open']({}, '/home/user');

      expect(result).toBeNull();
    });

    it('returns file content when file is selected', async () => {
      dialog.showOpenDialog.mockResolvedValue({
        canceled: false,
        filePaths: ['/home/user/notes.txt'],
      });
      fs.readFile.mockResolvedValue('Hello, world!');

      const result = await handlers['file:open']({}, '/home/user');

      expect(result).toEqual({
        path: '/home/user/notes.txt',
        content: 'Hello, world!',
      });
    });

    it('throws when file read fails', async () => {
      dialog.showOpenDialog.mockResolvedValue({
        canceled: false,
        filePaths: ['/restricted/file.txt'],
      });
      fs.readFile.mockRejectedValue(new Error('Permission denied'));

      await expect(handlers['file:open']({}, '/')).rejects.toThrow('Permission denied');
    });
  });

  describe('app:getVersion', () => {
    it('returns the app version', () => {
      app.getVersion.mockReturnValue('2.1.0');

      const result = handlers['app:getVersion']({});

      expect(result).toBe('2.1.0');
    });
  });
});

Testing the Preload Script

Preload scripts run in a sandboxed context but have access to both Node.js and browser APIs. Test the exposed API:

// src/main/preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  openFile: (defaultPath) => ipcRenderer.invoke('file:open', defaultPath),
  getVersion: () => ipcRenderer.invoke('app:getVersion'),
  onMenuAction: (callback) => {
    ipcRenderer.on('menu:action', (event, action) => callback(action));
    return () => ipcRenderer.removeAllListeners('menu:action');
  },
});
// src/main/preload.test.js
// Mock both electron modules
jest.mock('electron', () => ({
  contextBridge: {
    exposeInMainWorld: jest.fn(),
  },
  ipcRenderer: {
    invoke: jest.fn(),
    on: jest.fn(),
    removeAllListeners: jest.fn(),
  },
}));

const { contextBridge, ipcRenderer } = require('electron');

describe('preload script', () => {
  let exposedAPI;

  beforeEach(() => {
    contextBridge.exposeInMainWorld.mockImplementation((name, api) => {
      exposedAPI = api;
    });
    require('./preload');
  });

  it('exposes electronAPI to main world', () => {
    expect(contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
      'electronAPI',
      expect.any(Object)
    );
  });

  it('openFile invokes file:open IPC channel', () => {
    exposedAPI.openFile('/home/user');
    expect(ipcRenderer.invoke).toHaveBeenCalledWith('file:open', '/home/user');
  });

  it('onMenuAction registers listener and returns cleanup function', () => {
    const callback = jest.fn();
    const cleanup = exposedAPI.onMenuAction(callback);

    expect(ipcRenderer.on).toHaveBeenCalledWith('menu:action', expect.any(Function));
    expect(typeof cleanup).toBe('function');

    cleanup();
    expect(ipcRenderer.removeAllListeners).toHaveBeenCalledWith('menu:action');
  });
});

Testing Renderer Components

Renderer components that use window.electronAPI need the API mocked in jsdom:

// src/renderer/App.test.jsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import App from './App';

// Mock the electron API exposed by preload
const mockElectronAPI = {
  openFile: jest.fn(),
  getVersion: jest.fn(() => Promise.resolve('1.0.0')),
  onMenuAction: jest.fn(() => () => {}),
};

Object.defineProperty(window, 'electronAPI', {
  value: mockElectronAPI,
  writable: true,
});

describe('App', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('displays the app version on load', async () => {
    render(<App />);

    await waitFor(() => {
      expect(screen.getByText('v1.0.0')).toBeInTheDocument();
    });
  });

  it('opens file dialog on button click', async () => {
    mockElectronAPI.openFile.mockResolvedValue({
      path: '/home/user/notes.txt',
      content: 'My notes',
    });

    render(<App />);
    fireEvent.click(screen.getByRole('button', { name: /open file/i }));

    await waitFor(() => {
      expect(mockElectronAPI.openFile).toHaveBeenCalled();
      expect(screen.getByText('My notes')).toBeInTheDocument();
    });
  });
});

CI/CD Integration

Run Electron Jest tests in CI without a display server. Since unit tests don't launch Electron itself, they work fine in headless environments:

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage
      - uses: codecov/codecov-action@v4

For integration tests that launch Electron, use xvfb-run:

- run: xvfb-run --auto-servernum npm run test:e2e

Common Pitfalls

Forgetting to clear mocks between tests. Electron mock state bleeds between tests. Use beforeEach(() => jest.clearAllMocks()) in every describe block that uses Electron mocks.

Testing implementation details. Don't assert that ipcMain.handle was called with a specific function reference—test the behavior by extracting and calling the handler directly.

Missing async handling. IPC handlers are almost always async. Always await them in tests and use async/await consistently.

Skipping the preload test. The preload script is a critical security boundary. Test that it exposes exactly the right APIs and nothing more.

Summary

Testing Electron apps with Jest is straightforward once you mock the right modules. The pattern is consistent:

  1. Mock electron module for the main process
  2. Mock window.electronAPI for the renderer process
  3. Extract IPC handlers and test them directly
  4. Run tests in Node environment (main) or jsdom (renderer)

This approach gives you fast, reliable unit tests that run without launching Electron—keeping your CI pipeline quick and your feedback loop tight.

For end-to-end tests that exercise the full Electron runtime, pair Jest unit tests with Playwright for Electron or Spectron (deprecated, but documented).

Read more