Most IPC behavior can be tested without starting Electron. Register a module
against a small ipcMain fake, capture the callbacks it installs, and invoke
those callbacks directly. Keep one real-Electron smoke test for the boundary a
mock cannot cover: whether the generated preload bridge and runtime agree on
physical channel names.
electron module firstelectron-ipc-module imports ipcMain and BrowserWindow at module load, and
outside an Electron process electron resolves to the packaged binary rather
than the API — so a test runner needs a stub before any of the examples below
will even import. With Vitest, put one in a setup file:
// test/setup.ts
import { vi } from "vitest";
vi.mock("electron", () => ({
BrowserWindow: { getAllWindows: vi.fn(() => []) },
ipcMain: {
handle: vi.fn(),
handleOnce: vi.fn(),
on: vi.fn(),
once: vi.fn(),
removeHandler: vi.fn(),
removeListener: vi.fn(),
},
}));
// vitest.config.ts
export default defineConfig({
test: { environment: "node", setupFiles: ["./test/setup.ts"] },
});
The stub only has to cover what a test path actually reaches. Most tests pass
their own ipcMain fake to the register function and never touch the imported
one; createIpcEmitter().emit() is the case that does read
BrowserWindow.getAllWindows().
Avoid exporting handler callbacks only for tests. The function returned by
defineIpcModule accepts an ipcMain-compatible object, so a test can observe
the same registration path used in production.
import { describe, expect, it, vi } from "vitest";
import { defineIpcModule, handle, listen } from "electron-ipc-module";
function createIpcMainFake() {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const listeners = new Map<string, (...args: unknown[]) => unknown>();
return {
handlers,
listeners,
ipc: {
handle: vi.fn((channel, callback) => handlers.set(channel, callback)),
handleOnce: vi.fn((channel, callback) => handlers.set(channel, callback)),
on: vi.fn((channel, callback) => listeners.set(channel, callback)),
once: vi.fn((channel, callback) => listeners.set(channel, callback)),
removeHandler: vi.fn(),
removeListener: vi.fn(),
},
};
}
describe("profile IPC", () => {
it("registers and runs its channels", async () => {
const service = {
get: vi.fn(async (id: string) => ({ id, name: "Ada" })),
select: vi.fn(),
};
const register = defineIpcModule("profile", {
get: handle((_event, id: string) => service.get(id)),
select: listen((_event, id: string) => service.select(id)),
});
const fake = createIpcMainFake();
await register(fake.ipc as never);
const event = { sender: {}, senderFrame: null };
await expect(fake.handlers.get("profile:get")!(event, "user-1")).resolves.toEqual({
id: "user-1",
name: "Ada",
});
fake.listeners.get("profile:select")!(event, "user-1");
expect(service.select).toHaveBeenCalledWith("user-1");
});
});
This style checks prefixing, registration, guards, event wrapping, and the
handler itself. A test that calls service.get directly checks none of those.
Call the captured callback with untrusted runtime values. Do not use only TypeScript-valid inputs: the purpose of validation is to defend the erased runtime boundary.
import {
defineIpcModule,
handle,
IpcAuthorizationError,
IpcValidationError,
} from "electron-ipc-module";
const register = defineIpcModule(
"files",
{ read: handle((_event, path: string) => path) },
{
authorize: (event) => event.senderFrame?.url.startsWith("app://") === true,
validate: {
read: (args) => {
if (typeof args[0] !== "string") throw new TypeError("path must be a string");
},
},
},
);
Assert each stage separately:
IpcAuthorizationError;IpcValidationError whose issues are
preserved;listen failures reach onListenerError, because a fire-and-forget sender
cannot receive a rejected promise.See the error contract for the expected result at every stage.
Give the fake event a spied sender.send and reply. With
eventPrefix: true, assert the physical channel as well as its payload:
const sender = { send: vi.fn(), isDestroyed: () => false };
const event = { sender, senderFrame: null, reply: vi.fn() };
await capturedHandler(event);
expect(sender.send).toHaveBeenCalledWith("profile:updated", { id: "user-1" });
For createIpcEmitter().emitTo, provide a target with send and
isDestroyed. For broadcasts, mock BrowserWindow.getAllWindows() and assert
that live windows receive the event while destroyed ones do not.
isDestroyed matters on the handler's sender too: flip it to true to prove a
window closing mid-invoke drops the emit instead of throwing. Note that
callbacks receive a proxied event, so assert on sender.send and on individual
arguments rather than deep-equality against the fake event object.
Also test renderer listener cleanup at the component boundary. Every generated
on<Event> and once<Event> method returns an unsubscribe function; the test
should call it on unmount rather than relying on a later navigation to discard
the listener. See renderer patterns for the
hooks that make that automatic.
Use tiny register functions when the subject is the container rather than a channel:
const register = async () => ({
channels: [["profile:get", vi.fn()]] as const,
cleanup: vi.fn(),
});
const container = createIpcContainer();
await container.load("profile", register as never);
expect(container.names).toEqual(["profile"]);
expect(container.getChannels("profile")).toEqual(["profile:get"]);
High-value lifecycle cases are replacement with load, transactional rollback
with loadAll, channel collisions, cleanup failures, FIFO ordering of
overlapping calls, and the terminal behavior of dispose().
Commit the generated bridge, then make staleness a CI failure. Run check with
exactly the options generation uses — its defaults (./src/ipc,
./src/generated/ipc-bridge.ts, ./tsconfig.json) rarely match a real layout,
and a bare check pointed at the wrong paths proves nothing:
{
"scripts": {
"ipc:generate": "electron-ipc-module generate --ipc-dir ./main/ipc --out-file ./main/generated/ipc-bridge.ts --tsconfig ./tsconfig.preload.json --expose ipc",
"ipc:check": "electron-ipc-module check --ipc-dir ./main/ipc --out-file ./main/generated/ipc-bridge.ts --tsconfig ./tsconfig.preload.json --expose ipc"
}
}
- run: npm run ipc:check
Keeping both in scripts is what stops local generation and CI from drifting. Run the application's normal type check after that command. It verifies that renderer calls still match the newly generated API and catches stale global typing or an excluded generated file.
Mocks cannot prove that the runtime and generated preload use the same channel,
that the preload bundle loads in a sandbox, or that contextBridge exposes the
expected global. A small smoke test should therefore launch a hidden
BrowserWindow with production security settings and exercise at least:
invoke request and response;send;Give event assertions a timeout: a channel mismatch usually appears as an
event that never arrives. Report the verdict through a mechanism other than
the IPC path under test, such as webContents.executeJavaScript. The repository
example/smoke.ts is a complete reference.