Electron Unit Testing with Jest: Testing Main Process and Renderer

Electron Unit Testing with Jest: Testing Main Process and Renderer

End-to-end tests validate Electron applications but are slow and require a running Electron instance. Unit tests catch bugs faster, run in seconds, and don't need Electron installed. With Jest and the right mocking strategy, you can test both main process logic and renderer components without launching a single Electron window.

The Electron Testing Challenge

Electron code has two distinct environments:

  1. Main process — Node.js, runs as a regular Node process
  2. Renderer process — browser context (Chromium), but with Node access

Both import from the electron module, which is only available when running inside Electron. Unit tests run in Node.js or jsdom — they can't import real Electron APIs.

The solution is mocking: replace Electron's modules with test doubles that behave correctly without requiring the actual Electron runtime.

Setting Up Jest

npm install --save-dev jest @jest/globals
// package.json
{
  "jest": {
    "testEnvironment": "node",
    "moduleNameMapper": {
      "electron": "<rootDir>/tests/__mocks__/electron.js"
    }
  }
}

The moduleNameMapper replaces the electron module with your mock whenever any test file imports it.

Mocking Electron

Create tests/__mocks__/electron.js:

const electron = {
  app: {
    getVersion: jest.fn(() => '1.0.0'),
    getPath: jest.fn((name) => `/mock/paths/${name}`),
    quit: jest.fn(),
    on: jest.fn(),
    whenReady: jest.fn(() => Promise.resolve()),
  },
  BrowserWindow: jest.fn().mockImplementation(() => ({
    loadFile: jest.fn(),
    loadURL: jest.fn(),
    on: jest.fn(),
    once: jest.fn(),
    webContents: {
      send: jest.fn(),
      on: jest.fn(),
    },
    show: jest.fn(),
    hide: jest.fn(),
    close: jest.fn(),
    isDestroyed: jest.fn(() => false),
  })),
  ipcMain: {
    on: jest.fn(),
    handle: jest.fn(),
    removeHandler: jest.fn(),
  },
  ipcRenderer: {
    on: jest.fn(),
    send: jest.fn(),
    invoke: jest.fn(),
    removeListener: jest.fn(),
  },
  dialog: {
    showOpenDialog: jest.fn(),
    showSaveDialog: jest.fn(),
    showMessageBox: jest.fn(),
  },
  shell: {
    openExternal: jest.fn(),
    showItemInFolder: jest.fn(),
  },
  Menu: {
    buildFromTemplate: jest.fn(() => ({})),
    setApplicationMenu: jest.fn(),
  },
  nativeTheme: {
    shouldUseDarkColors: false,
    on: jest.fn(),
  }
};

module.exports = electron;

Testing Main Process Code

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

class WindowManager {
  constructor() {
    this.mainWindow = null;
  }

  createMainWindow() {
    this.mainWindow = new BrowserWindow({
      width: 1200,
      height: 800,
      webPreferences: {
        nodeIntegration: false,
        contextIsolation: true,
        preload: path.join(__dirname, 'preload.js')
      }
    });

    this.mainWindow.loadFile('index.html');
    this.mainWindow.on('closed', () => {
      this.mainWindow = null;
    });

    return this.mainWindow;
  }

  getMainWindow() {
    return this.mainWindow;
  }
}

module.exports = WindowManager;
// tests/main/windowManager.test.js
const { BrowserWindow } = require('electron');
const WindowManager = require('../../src/main/windowManager');

describe('WindowManager', () => {
  let manager;

  beforeEach(() => {
    jest.clearAllMocks();
    manager = new WindowManager();
  });

  test('creates main window with correct options', () => {
    manager.createMainWindow();

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

  test('loads index.html after creation', () => {
    const window = manager.createMainWindow();
    expect(window.loadFile).toHaveBeenCalledWith('index.html');
  });

  test('clears main window reference on close', () => {
    manager.createMainWindow();
    expect(manager.getMainWindow()).not.toBeNull();

    // Simulate the 'closed' event
    const closedCallback = BrowserWindow.mock.instances[0].on.mock.calls
      .find(([event]) => event === 'closed')[1];
    closedCallback();

    expect(manager.getMainWindow()).toBeNull();
  });
});

Testing IPC Handlers

IPC (inter-process communication) is how main and renderer processes communicate. Test handlers in isolation:

// src/main/ipcHandlers.js
const { ipcMain, dialog } = require('electron');
const fs = require('fs').promises;

function registerHandlers() {
  ipcMain.handle('open-file', async () => {
    const { canceled, filePaths } = await dialog.showOpenDialog({
      properties: ['openFile'],
      filters: [{ name: 'Documents', extensions: ['txt', 'md'] }]
    });

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

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

  ipcMain.handle('save-file', async (event, { path, content }) => {
    await fs.writeFile(path, content, 'utf-8');
    return { success: true };
  });
}

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

jest.mock('fs', () => ({
  promises: {
    readFile: jest.fn(),
    writeFile: jest.fn()
  }
}));

describe('IPC handlers', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    registerHandlers();
  });

  test('open-file handler returns file content', async () => {
    dialog.showOpenDialog.mockResolvedValue({
      canceled: false,
      filePaths: ['/home/user/document.txt']
    });
    fs.readFile.mockResolvedValue('Hello, world!');

    // Extract and invoke the handler
    const [, handler] = ipcMain.handle.mock.calls
      .find(([channel]) => channel === 'open-file');

    const result = await handler({});

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

  test('open-file handler returns null when cancelled', async () => {
    dialog.showOpenDialog.mockResolvedValue({ canceled: true, filePaths: [] });

    const [, handler] = ipcMain.handle.mock.calls
      .find(([channel]) => channel === 'open-file');

    const result = await handler({});
    expect(result).toBeNull();
  });
});

Testing the Renderer with jsdom

Renderer code that doesn't use Electron-specific APIs can be tested with jsdom (Jest's default browser environment):

// jest.config.js for renderer tests
{
  "projects": [
    {
      "displayName": "main",
      "testEnvironment": "node",
      "testMatch": ["<rootDir>/tests/main/**/*.test.js"],
      "moduleNameMapper": { "electron": "<rootDir>/tests/__mocks__/electron.js" }
    },
    {
      "displayName": "renderer",
      "testEnvironment": "jsdom",
      "testMatch": ["<rootDir>/tests/renderer/**/*.test.js"],
      "moduleNameMapper": { "electron": "<rootDir>/tests/__mocks__/electron.js" }
    }
  ]
}
// tests/renderer/fileList.test.js
const { renderFileList } = require('../../src/renderer/fileList');

describe('renderFileList', () => {
  test('renders list of files', () => {
    document.body.innerHTML = '<ul id="file-list"></ul>';

    const files = [
      { name: 'document.txt', size: 1024 },
      { name: 'image.png', size: 204800 }
    ];

    renderFileList(files, document.getElementById('file-list'));

    const items = document.querySelectorAll('li');
    expect(items).toHaveLength(2);
    expect(items[0]).toHaveTextContent('document.txt');
    expect(items[1]).toHaveTextContent('image.png');
  });
});

Testing the Preload Script

The preload script bridges main and renderer safely. Test it by mocking contextBridge:

// tests/__mocks__/electron.js — add to your mock
module.exports = {
  // ... existing mocks
  contextBridge: {
    exposeInMainWorld: jest.fn(),
  }
};
// tests/preload.test.js
const { contextBridge, ipcRenderer } = require('electron');

// Load the preload script
require('../../src/preload');

test('exposes expected API to renderer', () => {
  expect(contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
    'electronAPI',
    expect.objectContaining({
      openFile: expect.any(Function),
      saveFile: expect.any(Function),
      onThemeChange: expect.any(Function)
    })
  );
});

test('openFile invokes ipc channel', () => {
  const [, api] = contextBridge.exposeInMainWorld.mock.calls
    .find(([name]) => name === 'electronAPI');

  ipcRenderer.invoke.mockResolvedValue({ path: '/test.txt', content: 'hi' });

  api.openFile();
  expect(ipcRenderer.invoke).toHaveBeenCalledWith('open-file');
});

Running Tests

# All tests
npx jest

<span class="hljs-comment"># Watch mode
npx jest --watch

<span class="hljs-comment"># Specific file
npx jest tests/main/windowManager.test.js

<span class="hljs-comment"># Coverage
npx jest --coverage

End-to-End Tests as the Next Layer

Unit tests verify logic quickly. For full user flow validation in a real Electron window, use Playwright's Electron support. HelpMeTest provides an additional layer: continuous monitoring of the backend services and APIs your Electron app connects to, ensuring they're available around the clock.

Summary

Unit testing Electron with Jest requires one key insight: mock the electron module completely. With that in place:

  • Main process code tests like any Node.js module
  • IPC handlers are extracted and tested independently
  • Renderer code that doesn't need native APIs runs in jsdom
  • Preload scripts are tested by asserting on contextBridge calls

Fast unit tests catch logic errors in seconds; use them for coverage of your core logic, and complement with Playwright E2E tests for user flow validation.

Read more