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:
- Main process — Node.js, runs as a regular Node process
- 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 --coverageEnd-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
contextBridgecalls
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.