electron-ipc-module
    Preparing search index...

    Testing IPC modules

    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-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:

    • an untrusted frame rejects with IpcAuthorizationError;
    • malformed input rejects before the handler runs;
    • a Standard Schema failure is an IpcValidationError whose issues are preserved;
    • parsed Standard Schema output, including coercion or field stripping, is what the handler receives;
    • 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:

    • one invoke request and response;
    • one fire-and-forget send;
    • one main-to-renderer event;
    • the built, bundled preload rather than a test-only replacement.

    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.