Electron Unit Testing with Jest: Main Process and Renderer
Electron E2E testing with Playwright tests the full application, but unit tests with Jest run in milliseconds and catch business logic bugs during development. The challenge is that Electron's main process and renderer both have APIs that don't exist in Node.js or jsdom test environments.
This guide covers unit testing Electron apps with Jest: mocking Electron APIs, testing IPC handlers, testing renderer logic, and testing without launching a browser window.
Setting Up Jest for Electron
Electron apps have two distinct environments:
- Main process: Node.js with access to
electronmodule - Renderer process: Chrome/Chromium with web APIs + Electron's
contextBridge
Configure Jest to test each separately:
// jest.config.js
module.exports = {
projects: [
{
displayName: 'main',
testMatch: ['<rootDir>/src/main/**/*.test.ts'],
testEnvironment: 'node',
transform: { '^.+\\.tsx?$': 'ts-jest' },
moduleNameMapper: {
'^electron$': '<rootDir>/tests/__mocks__/electron.js',
},
},
{
displayName: 'renderer',
testMatch: ['<rootDir>/src/renderer/**/*.test.ts'],
testEnvironment: 'jsdom',
transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.renderer.json' }] },
moduleNameMapper: {
'^electron$': '<rootDir>/tests/__mocks__/electron-renderer.js',
},
},
],
};Mocking the Electron Module
// tests/__mocks__/electron.js
const electronMock = {
app: {
getPath: jest.fn().mockReturnValue('/tmp/test-user-data'),
getVersion: jest.fn().mockReturnValue('1.0.0'),
quit: jest.fn(),
on: jest.fn(),
whenReady: jest.fn().mockResolvedValue(undefined),
},
BrowserWindow: jest.fn().mockImplementation(() => ({
loadURL: jest.fn().mockResolvedValue(undefined),
loadFile: jest.fn().mockResolvedValue(undefined),
webContents: { send: jest.fn(), openDevTools: jest.fn() },
on: jest.fn(),
once: jest.fn(),
show: jest.fn(),
close: jest.fn(),
isDestroyed: jest.fn().mockReturnValue(false),
getSize: jest.fn().mockReturnValue([1200, 800]),
})),
ipcMain: {
handle: jest.fn(),
on: jest.fn(),
removeHandler: jest.fn(),
},
dialog: {
showOpenDialog: jest.fn(),
showSaveDialog: jest.fn(),
showOpenDialogSync: jest.fn(),
showSaveDialogSync: jest.fn(),
showMessageBoxSync: jest.fn().mockReturnValue(0),
},
shell: {
openExternal: jest.fn().mockResolvedValue(undefined),
},
};
module.exports = electronMock;Testing Main Process IPC Handlers
// src/main/handlers/file-handler.ts
import { ipcMain, dialog } from 'electron';
import fs from 'fs/promises';
export function registerFileHandlers() {
ipcMain.handle('file:open', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Text files', extensions: ['txt', 'md'] }],
});
if (result.canceled || !result.filePaths[0]) {
return { canceled: true };
}
const filePath = result.filePaths[0];
const content = await fs.readFile(filePath, 'utf-8');
return { canceled: false, filePath, content };
});
ipcMain.handle('file:save', async (_, { filePath, content }: { filePath: string; content: string }) => {
await fs.writeFile(filePath, content, 'utf-8');
return { success: true };
});
}// src/main/handlers/file-handler.test.ts
import { ipcMain, dialog } from 'electron';
import fs from 'fs/promises';
import { registerFileHandlers } from './file-handler';
jest.mock('fs/promises');
describe('File handlers', () => {
beforeEach(() => {
jest.clearAllMocks();
registerFileHandlers();
});
function getHandler(channel: string) {
const call = (ipcMain.handle as jest.Mock).mock.calls.find(([c]) => c === channel);
return call?.[1];
}
describe('file:open', () => {
it('reads file when user selects one', async () => {
(dialog.showOpenDialog as jest.Mock).mockResolvedValue({
canceled: false,
filePaths: ['/home/user/document.txt'],
});
(fs.readFile as jest.Mock).mockResolvedValue('Hello, file content!');
const handler = getHandler('file:open');
const result = await handler();
expect(result).toEqual({
canceled: false,
filePath: '/home/user/document.txt',
content: 'Hello, file content!',
});
expect(fs.readFile).toHaveBeenCalledWith('/home/user/document.txt', 'utf-8');
});
it('returns canceled when user dismisses dialog', async () => {
(dialog.showOpenDialog as jest.Mock).mockResolvedValue({
canceled: true,
filePaths: [],
});
const handler = getHandler('file:open');
const result = await handler();
expect(result.canceled).toBe(true);
expect(fs.readFile).not.toHaveBeenCalled();
});
});
describe('file:save', () => {
it('writes content to specified path', async () => {
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
const handler = getHandler('file:save');
const result = await handler(null, {
filePath: '/home/user/output.txt',
content: 'Save this content',
});
expect(result).toEqual({ success: true });
expect(fs.writeFile).toHaveBeenCalledWith(
'/home/user/output.txt',
'Save this content',
'utf-8'
);
});
});
});Testing Renderer Hooks
// src/renderer/hooks/useFileOperations.ts
import { useState, useCallback } from 'react';
export function useFileOperations() {
const [fileState, setFileState] = useState({
content: '',
filePath: null as string | null,
isDirty: false,
});
const openFile = useCallback(async () => {
const result = await window.electron.ipcRenderer.invoke('file:open');
if (!result.canceled) {
setFileState({ content: result.content, filePath: result.filePath, isDirty: false });
}
}, []);
const saveFile = useCallback(async () => {
if (!fileState.filePath) return;
await window.electron.ipcRenderer.invoke('file:save', {
filePath: fileState.filePath,
content: fileState.content,
});
setFileState((prev) => ({ ...prev, isDirty: false }));
}, [fileState]);
const updateContent = useCallback((content: string) => {
setFileState((prev) => ({ ...prev, content, isDirty: true }));
}, []);
return { ...fileState, openFile, saveFile, updateContent };
}// src/renderer/hooks/useFileOperations.test.ts
import { renderHook, act } from '@testing-library/react';
import { useFileOperations } from './useFileOperations';
// window.electron is set in tests setup file
describe('useFileOperations', () => {
beforeEach(() => jest.clearAllMocks());
it('starts with empty state', () => {
const { result } = renderHook(() => useFileOperations());
expect(result.current.content).toBe('');
expect(result.current.filePath).toBeNull();
expect(result.current.isDirty).toBe(false);
});
it('loads file content after openFile', async () => {
(window.electron.ipcRenderer.invoke as jest.Mock).mockResolvedValue({
canceled: false,
filePath: '/path/to/file.txt',
content: 'Loaded content',
});
const { result } = renderHook(() => useFileOperations());
await act(async () => { await result.current.openFile(); });
expect(result.current.content).toBe('Loaded content');
expect(result.current.isDirty).toBe(false);
});
it('marks file as dirty when content changes', () => {
const { result } = renderHook(() => useFileOperations());
act(() => { result.current.updateContent('New content'); });
expect(result.current.isDirty).toBe(true);
});
it('clears dirty flag after save', async () => {
(window.electron.ipcRenderer.invoke as jest.Mock)
.mockResolvedValueOnce({ canceled: false, filePath: '/file.txt', content: 'Original' })
.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useFileOperations());
await act(async () => { await result.current.openFile(); });
act(() => { result.current.updateContent('Modified'); });
expect(result.current.isDirty).toBe(true);
await act(async () => { await result.current.saveFile(); });
expect(result.current.isDirty).toBe(false);
});
});Summary
Electron unit testing with Jest:
- Configure two Jest projects —
nodeenvironment for main,jsdomfor renderer - Mock the
electronmodule —BrowserWindow,ipcMain,dialog,shell - Extract IPC handlers from
ipcMain.handlemock calls to test them directly - Test renderer hooks with
renderHookfrom Testing Library — no Electron launch needed - Keep tests focused — unit tests verify logic; E2E tests verify the composed application
Unit tests run in milliseconds and give immediate feedback during development. Use them for all business logic; use Playwright for the final integration verification.