Testing Electron IPC: Main Process and Renderer Communication
Inter-Process Communication (IPC) is the nervous system of an Electron app. The renderer process (UI) can't directly access Node.js APIs — it communicates with the main process via IPC channels. When IPC breaks, the app breaks. When IPC is poorly tested, bugs hide at the process boundary until production.
Testing IPC well requires testing both sides separately and together. This guide covers unit testing IPC handlers, testing the renderer side without a full Electron environment, and integration testing the full communication path.
How Electron IPC Works
In a secure Electron app using contextIsolation: true (which it should be):
- Renderer process: Can only use APIs exposed via
contextBridgein the preload script - Preload script: Runs in a privileged context, exposes safe APIs to the renderer
- Main process: Receives messages via
ipcMain.handle()and responds
Renderer → contextBridge.exposeInMainWorld → preload → ipcRenderer.invoke → ipcMain.handle → Main ProcessThis architecture has three testable layers.
Layer 1: Unit Testing ipcMain Handlers
The main process handlers are pure Node.js functions that happen to be registered on IPC channels. Extract the handler logic into testable functions:
// main/handlers/files.js
const { dialog, app } = require('electron');
const path = require('path');
const fs = require('fs/promises');
// Handler logic extracted for testability
async function handleOpenFile() {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Documents', extensions: ['txt', 'md', 'pdf'] }],
});
if (canceled || filePaths.length === 0) return null;
const content = await fs.readFile(filePaths[0], 'utf-8');
return { path: filePaths[0], content };
}
async function handleSaveFile(event, { path: filePath, content }) {
if (!filePath) {
const { canceled, filePath: newPath } = await dialog.showSaveDialog({
defaultPath: path.join(app.getPath('documents'), 'untitled.txt'),
});
if (canceled) return null;
filePath = newPath;
}
await fs.writeFile(filePath, content, 'utf-8');
return { path: filePath, saved: true };
}
// Register handlers
function registerFileHandlers(ipcMain) {
ipcMain.handle('file:open', handleOpenFile);
ipcMain.handle('file:save', handleSaveFile);
}
module.exports = { handleOpenFile, handleSaveFile, registerFileHandlers };Now test the handlers without needing a full Electron environment:
// tests/main/handlers/files.test.js
jest.mock('electron', () => ({
dialog: {
showOpenDialog: jest.fn(),
showSaveDialog: jest.fn(),
},
app: {
getPath: jest.fn(() => '/home/user'),
},
}));
jest.mock('fs/promises', () => ({
readFile: jest.fn(),
writeFile: jest.fn(),
}));
const { dialog } = require('electron');
const fs = require('fs/promises');
const { handleOpenFile, handleSaveFile } = require('../../../main/handlers/files');
describe('handleOpenFile', () => {
beforeEach(() => jest.clearAllMocks());
it('returns file content when user selects a file', async () => {
dialog.showOpenDialog.mockResolvedValue({
canceled: false,
filePaths: ['/home/user/doc.txt'],
});
fs.readFile.mockResolvedValue('File content here');
const result = await handleOpenFile();
expect(result).toEqual({
path: '/home/user/doc.txt',
content: 'File content here',
});
});
it('returns null when user cancels the dialog', async () => {
dialog.showOpenDialog.mockResolvedValue({ canceled: true, filePaths: [] });
const result = await handleOpenFile();
expect(result).toBeNull();
expect(fs.readFile).not.toHaveBeenCalled();
});
it('reads file with utf-8 encoding', async () => {
dialog.showOpenDialog.mockResolvedValue({
canceled: false,
filePaths: ['/home/user/file.txt'],
});
fs.readFile.mockResolvedValue('content');
await handleOpenFile();
expect(fs.readFile).toHaveBeenCalledWith('/home/user/file.txt', 'utf-8');
});
});
describe('handleSaveFile', () => {
it('saves to existing path without dialog', async () => {
fs.writeFile.mockResolvedValue(undefined);
const result = await handleSaveFile({}, {
path: '/existing/file.txt',
content: 'Updated content',
});
expect(fs.writeFile).toHaveBeenCalledWith('/existing/file.txt', 'Updated content', 'utf-8');
expect(dialog.showSaveDialog).not.toHaveBeenCalled();
expect(result.saved).toBe(true);
});
it('shows save dialog when no path provided', async () => {
dialog.showSaveDialog.mockResolvedValue({
canceled: false,
filePath: '/new/file.txt',
});
fs.writeFile.mockResolvedValue(undefined);
const result = await handleSaveFile({}, { path: null, content: 'New file' });
expect(dialog.showSaveDialog).toHaveBeenCalled();
expect(result.path).toBe('/new/file.txt');
});
});Layer 2: Testing the Preload Script
The preload script is the bridge. It runs in a special context and uses contextBridge.exposeInMainWorld. Unit testing it requires mocking contextBridge and ipcRenderer:
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('file:open'),
saveFile: (data) => ipcRenderer.invoke('file:save', data),
onThemeChange: (callback) => {
ipcRenderer.on('theme:change', (event, theme) => callback(theme));
},
removeThemeListener: () => ipcRenderer.removeAllListeners('theme:change'),
});// tests/preload.test.js
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;
beforeAll(() => {
require('../preload');
// Capture the exposed API
exposedAPI = contextBridge.exposeInMainWorld.mock.calls[0][1];
});
beforeEach(() => jest.clearAllMocks());
it('exposes electronAPI to the window', () => {
expect(contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
'electronAPI',
expect.any(Object)
);
});
it('openFile invokes correct IPC channel', () => {
ipcRenderer.invoke.mockResolvedValue({ path: '/file.txt', content: 'data' });
exposedAPI.openFile();
expect(ipcRenderer.invoke).toHaveBeenCalledWith('file:open');
});
it('saveFile passes data to IPC channel', () => {
const saveData = { path: '/file.txt', content: 'new content' };
ipcRenderer.invoke.mockResolvedValue({ saved: true });
exposedAPI.saveFile(saveData);
expect(ipcRenderer.invoke).toHaveBeenCalledWith('file:save', saveData);
});
it('onThemeChange registers listener with correct channel', () => {
const callback = jest.fn();
exposedAPI.onThemeChange(callback);
expect(ipcRenderer.on).toHaveBeenCalledWith('theme:change', expect.any(Function));
// Simulate the IPC event firing
const registeredHandler = ipcRenderer.on.mock.calls[0][1];
registeredHandler(null, 'dark'); // (event, theme)
expect(callback).toHaveBeenCalledWith('dark');
});
});Layer 3: Testing the Renderer Usage
The renderer uses window.electronAPI — but in tests, window.electronAPI doesn't exist unless you mock it:
// src/components/FileEditor.jsx
function FileEditor() {
const [content, setContent] = useState('');
const [filePath, setFilePath] = useState(null);
const handleOpen = async () => {
const result = await window.electronAPI.openFile();
if (result) {
setContent(result.content);
setFilePath(result.path);
}
};
return (
<div>
<button onClick={handleOpen} data-testid="open-button">Open File</button>
<textarea value={content} onChange={e => setContent(e.target.value)} />
</div>
);
}// tests/components/FileEditor.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import FileEditor from '../../src/components/FileEditor';
// Mock window.electronAPI before each test
beforeEach(() => {
window.electronAPI = {
openFile: jest.fn(),
saveFile: jest.fn(),
onThemeChange: jest.fn(),
removeThemeListener: jest.fn(),
};
});
afterEach(() => {
delete window.electronAPI;
});
describe('FileEditor', () => {
it('opens file and displays content', async () => {
window.electronAPI.openFile.mockResolvedValue({
path: '/doc.txt',
content: 'File content to display',
});
render(<FileEditor />);
fireEvent.click(screen.getByTestId('open-button'));
await screen.findByDisplayValue('File content to display');
expect(window.electronAPI.openFile).toHaveBeenCalled();
});
it('does nothing when user cancels file dialog', async () => {
window.electronAPI.openFile.mockResolvedValue(null); // User cancelled
render(<FileEditor />);
fireEvent.click(screen.getByTestId('open-button'));
await new Promise(resolve => setTimeout(resolve, 0));
// Textarea should remain empty
expect(screen.getByRole('textbox')).toHaveValue('');
});
});Integration Testing: Full IPC Round-Trip
Unit tests verify each layer independently. Integration tests verify they work together in a real Electron process. Use @playwright/test with Electron:
// tests/integration/ipc.spec.js
const { _electron: electron } = require('@playwright/test');
const { test, expect } = require('@playwright/test');
const path = require('path');
test.describe('IPC integration', () => {
let app;
let page;
test.beforeEach(async () => {
app = await electron.launch({
args: [path.join(__dirname, '../../main.js')],
env: {
NODE_ENV: 'test',
...process.env,
},
});
page = await app.firstWindow();
});
test.afterEach(() => app.close());
test('file open IPC round-trip', async () => {
// Intercept the dialog
app.evaluate(({ dialog }) => {
dialog.showOpenDialog = () => Promise.resolve({
canceled: false,
filePaths: ['/tmp/test-file.txt'],
});
});
// Click open file button in UI
await page.click('[data-testid="open-button"]');
// Verify file content loaded (you'd have written /tmp/test-file.txt in beforeEach)
await expect(page.locator('textarea')).toHaveValue('Test file content');
});
});Testing IPC Error Handling
IPC errors need to be handled gracefully — an unhandled IPC rejection can crash the renderer:
describe('IPC error handling', () => {
it('handles file read permission error gracefully', async () => {
const { handleOpenFile } = require('../../../main/handlers/files');
dialog.showOpenDialog.mockResolvedValue({
canceled: false,
filePaths: ['/protected/file.txt'],
});
fs.readFile.mockRejectedValue(new Error('EACCES: permission denied'));
// Should throw — caller handles the error
await expect(handleOpenFile()).rejects.toThrow('EACCES');
});
it('renderer displays error message when IPC fails', async () => {
window.electronAPI.openFile.mockRejectedValue(new Error('Failed to read file'));
render(<FileEditor />);
fireEvent.click(screen.getByTestId('open-button'));
await screen.findByTestId('error-message');
expect(screen.getByTestId('error-message')).toHaveTextContent('Failed to read file');
});
});Common IPC Testing Mistakes
Not testing the "canceled" path: Dialog handlers must handle cancellation. Many bugs come from code that tries to use filePaths[0] when canceled: true and filePaths is empty.
Not testing ipcRenderer.on vs ipcMain.send events: One-way events (main → renderer pushes) are different from request-response invoke. Test them separately.
Testing IPC channels by string: Hardcoded channel strings like 'file:open' can drift between the preload and main. Create a shared constants file:
// shared/ipc-channels.js
module.exports = {
FILE_OPEN: 'file:open',
FILE_SAVE: 'file:save',
THEME_CHANGE: 'theme:change',
};Import this in both preload and main process — then tests that verify the channel name will catch drift.
Summary
Testing Electron IPC well means testing each layer independently:
- Main process handlers: Pure functions with mocked Electron APIs. Test all happy paths, cancellations, and errors.
- Preload script: Mock
contextBridgeandipcRenderer, verify the exposed API invokes correct channels. - Renderer components: Mock
window.electronAPI, test how the UI responds to IPC results. - Integration: Full Playwright/Electron test that verifies the round-trip works in a real process.
The most common IPC bugs — unhandled dialog cancellations, channel name mismatches, and missing error handling — are all caught by this testing approach.