IPC Communication Testing in Electron and Tauri Apps

IPC Communication Testing in Electron and Tauri Apps

IPC (Inter-Process Communication) is the nervous system of any Electron or Tauri application. It's how your frontend requests data from the backend, how your Rust or Node.js process signals the UI about state changes, and how you enforce security boundaries between privileged and unprivileged code. It's also one of the most commonly undertested layers in desktop applications.

When IPC goes wrong, the failures are subtle: a dialog doesn't open, a file operation silently succeeds but returns stale data, a security check that should reject a crafted payload instead lets it through. E2E tests catch some of this, but they're slow and give poor precision. This guide covers IPC testing at multiple levels — unit, integration, and security contract — for both Electron and Tauri.

Why IPC Testing Deserves Its Own Strategy

The IPC boundary is where several failure modes concentrate:

  1. Serialization bugs — Objects that don't survive the structured clone algorithm (functions, class instances with methods, Symbols)
  2. Security gaps — Handlers that trust renderer-provided paths, IDs, or permissions without validation
  3. Race conditions — Multiple concurrent IPC calls that mutate shared state
  4. Error propagation — Errors thrown in handlers that swallow stack traces or return wrong error shapes
  5. Event leaks — Listeners registered on every ipcMain.on() call without corresponding cleanup

Testing IPC in isolation, before integration, surfaces these issues cheaply.

Electron IPC: Architecture Overview

Electron IPC has two message patterns:

  • Request-response: ipcRenderer.invoke() → ipcMain.handle() — returns a Promise
  • Fire-and-forget: ipcRenderer.send() → ipcMain.on() / webContents.send() → ipcRenderer.on()

Best practice is to use invoke/handle for almost everything — it gives you backpressure and error propagation for free. The send/on pattern is useful for push notifications from the main process (e.g., download progress, file watcher events).

Unit Testing Electron IPC Handlers

The most effective pattern is to separate handler registration from handler logic:

// src/main/handlers/file-handler.ts
import { app } from 'electron';
import { promises as fs } from 'fs';
import path from 'path';

export interface FileReadRequest {
  filePath: string;
  encoding?: BufferEncoding;
}

export interface FileReadResponse {
  content: string;
  size: number;
  modifiedAt: number;
}

export class FileHandler {
  private allowedRoot: string;

  constructor(allowedRoot?: string) {
    this.allowedRoot = allowedRoot ?? app.getPath('home');
  }

  async readFile(request: FileReadRequest): Promise<FileReadResponse> {
    this.validatePath(request.filePath);
    
    const resolved = path.resolve(request.filePath);
    const stat = await fs.stat(resolved);
    const content = await fs.readFile(resolved, request.encoding ?? 'utf-8');

    return {
      content,
      size: stat.size,
      modifiedAt: stat.mtimeMs,
    };
  }

  async writeFile(filePath: string, content: string): Promise<void> {
    this.validatePath(filePath);
    await fs.writeFile(path.resolve(filePath), content, 'utf-8');
  }

  async listDirectory(dirPath: string): Promise<string[]> {
    this.validatePath(dirPath);
    const entries = await fs.readdir(path.resolve(dirPath));
    return entries;
  }

  private validatePath(filePath: string): void {
    const resolved = path.resolve(filePath);
    if (!resolved.startsWith(this.allowedRoot)) {
      throw new Error(`SecurityError: path '${filePath}' is outside allowed root`);
    }
  }
}

Now the handler class is a pure class you can unit test completely:

// src/main/handlers/file-handler.test.ts
import { FileHandler } from './file-handler';
import { promises as fs } from 'fs';
import path from 'path';

jest.mock('fs', () => ({
  promises: {
    stat: jest.fn(),
    readFile: jest.fn(),
    writeFile: jest.fn(),
    readdir: jest.fn(),
  },
}));

jest.mock('electron', () => ({
  app: { getPath: jest.fn().mockReturnValue('/home/user') },
}));

describe('FileHandler', () => {
  let handler: FileHandler;

  beforeEach(() => {
    jest.clearAllMocks();
    handler = new FileHandler('/home/user');
  });

  describe('readFile', () => {
    it('reads a file within the allowed root', async () => {
      (fs.stat as jest.Mock).mockResolvedValueOnce({ size: 42, mtimeMs: 1700000000000 });
      (fs.readFile as jest.Mock).mockResolvedValueOnce('hello world');

      const result = await handler.readFile({ filePath: '/home/user/docs/file.txt' });

      expect(result.content).toBe('hello world');
      expect(result.size).toBe(42);
      expect(result.modifiedAt).toBe(1700000000000);
    });

    it('throws SecurityError for paths outside allowed root', async () => {
      await expect(
        handler.readFile({ filePath: '/etc/passwd' })
      ).rejects.toThrow('SecurityError');

      expect(fs.readFile).not.toHaveBeenCalled();
    });

    it('throws SecurityError for path traversal attempts', async () => {
      await expect(
        handler.readFile({ filePath: '/home/user/../../etc/passwd' })
      ).rejects.toThrow('SecurityError');
    });

    it('propagates fs errors with their original message', async () => {
      (fs.stat as jest.Mock).mockRejectedValueOnce(
        Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
      );

      await expect(
        handler.readFile({ filePath: '/home/user/missing.txt' })
      ).rejects.toThrow('ENOENT');
    });
  });

  describe('listDirectory', () => {
    it('returns directory entries as string array', async () => {
      (fs.readdir as jest.Mock).mockResolvedValueOnce(['a.txt', 'b.txt', 'subdir']);

      const result = await handler.listDirectory('/home/user/docs');
      expect(result).toEqual(['a.txt', 'b.txt', 'subdir']);
    });
  });
});

Testing IPC Registration

The registration layer connects handler methods to ipcMain. Test it separately to verify channel names and error wrapping:

// src/main/handlers/register-handlers.ts
import { ipcMain, IpcMainInvokeEvent } from 'electron';
import { FileHandler } from './file-handler';

export function registerHandlers(fileHandler: FileHandler): void {
  ipcMain.handle('file:read', async (_event: IpcMainInvokeEvent, request) => {
    return fileHandler.readFile(request);
  });

  ipcMain.handle('file:write', async (_event: IpcMainInvokeEvent, filePath, content) => {
    return fileHandler.writeFile(filePath, content);
  });

  ipcMain.handle('file:list', async (_event: IpcMainInvokeEvent, dirPath) => {
    return fileHandler.listDirectory(dirPath);
  });
}
// src/main/handlers/register-handlers.test.ts
import { ipcMain } from 'electron';
import { registerHandlers } from './register-handlers';
import { FileHandler } from './file-handler';

describe('registerHandlers', () => {
  let mockFileHandler: jest.Mocked<FileHandler>;
  const mockEvent = {} as Electron.IpcMainInvokeEvent;

  beforeEach(() => {
    jest.clearAllMocks();
    mockFileHandler = {
      readFile: jest.fn(),
      writeFile: jest.fn(),
      listDirectory: jest.fn(),
    } as any;
    
    registerHandlers(mockFileHandler);
  });

  it('registers the expected IPC channels', () => {
    const handleCalls = (ipcMain.handle as jest.Mock).mock.calls;
    const registeredChannels = handleCalls.map(call => call[0]);
    
    expect(registeredChannels).toContain('file:read');
    expect(registeredChannels).toContain('file:write');
    expect(registeredChannels).toContain('file:list');
  });

  it('delegates file:read to FileHandler.readFile', async () => {
    mockFileHandler.readFile.mockResolvedValueOnce({
      content: 'test',
      size: 4,
      modifiedAt: 1700000000,
    });

    // Find and call the registered handler directly
    const handleCalls = (ipcMain.handle as jest.Mock).mock.calls;
    const readHandler = handleCalls.find(([channel]) => channel === 'file:read')?.[1];
    
    const result = await readHandler(mockEvent, { filePath: '/home/user/test.txt' });
    
    expect(mockFileHandler.readFile).toHaveBeenCalledWith({ filePath: '/home/user/test.txt' });
    expect(result.content).toBe('test');
  });
});

Testing Push Notifications from Main to Renderer

When the main process needs to push data to the renderer (file watcher events, download progress), use webContents.send(). Test these by capturing what webContents.send() was called with:

// src/main/watchers/file-watcher.ts
import { BrowserWindow } from 'electron';
import chokidar, { FSWatcher } from 'chokidar';

export class FileWatcher {
  private watcher: FSWatcher | null = null;
  private window: BrowserWindow;

  constructor(window: BrowserWindow) {
    this.window = window;
  }

  watch(directory: string): void {
    this.watcher = chokidar.watch(directory, { ignoreInitial: true });
    
    this.watcher.on('add', (filePath) => {
      if (!this.window.isDestroyed()) {
        this.window.webContents.send('file:added', { filePath });
      }
    });

    this.watcher.on('change', (filePath) => {
      if (!this.window.isDestroyed()) {
        this.window.webContents.send('file:changed', { filePath });
      }
    });
  }

  stop(): void {
    this.watcher?.close();
    this.watcher = null;
  }
}
// src/main/watchers/file-watcher.test.ts
import { FileWatcher } from './file-watcher';
import chokidar from 'chokidar';

jest.mock('chokidar');
jest.mock('electron', () => ({
  BrowserWindow: jest.fn(),
}));

describe('FileWatcher', () => {
  let mockWindow: any;
  let mockWatcher: any;
  let eventHandlers: Record<string, (path: string) => void>;

  beforeEach(() => {
    eventHandlers = {};
    mockWatcher = {
      on: jest.fn().mockImplementation((event, handler) => {
        eventHandlers[event] = handler;
        return mockWatcher;
      }),
      close: jest.fn(),
    };
    
    (chokidar.watch as jest.Mock).mockReturnValue(mockWatcher);
    
    mockWindow = {
      isDestroyed: jest.fn().mockReturnValue(false),
      webContents: { send: jest.fn() },
    };
  });

  it('sends file:added event when a file is added', () => {
    const watcher = new FileWatcher(mockWindow);
    watcher.watch('/home/user/docs');
    
    // Simulate chokidar firing the 'add' event
    eventHandlers['add']('/home/user/docs/newfile.txt');
    
    expect(mockWindow.webContents.send).toHaveBeenCalledWith('file:added', {
      filePath: '/home/user/docs/newfile.txt',
    });
  });

  it('does not send if window is destroyed', () => {
    mockWindow.isDestroyed.mockReturnValue(true);
    
    const watcher = new FileWatcher(mockWindow);
    watcher.watch('/home/user/docs');
    
    eventHandlers['add']('/home/user/docs/newfile.txt');
    
    expect(mockWindow.webContents.send).not.toHaveBeenCalled();
  });
});

Tauri IPC: Testing Commands

Tauri commands are Rust functions annotated with #[tauri::command]. The frontend calls them via invoke(). On the Rust side, test commands with standard Rust unit tests. On the frontend side, test the JavaScript wrapper that calls invoke().

For the Rust side:

// src-tauri/src/commands/file_commands.rs
use std::path::PathBuf;
use tauri::AppHandle;

#[tauri::command]
pub async fn read_file(
    app: AppHandle,
    file_path: String,
) -> Result<String, String> {
    let home = app
        .path()
        .home_dir()
        .map_err(|e| e.to_string())?;
    
    let path = PathBuf::from(&file_path);
    let resolved = path.canonicalize().map_err(|e| e.to_string())?;
    
    if !resolved.starts_with(&home) {
        return Err(format!("SecurityError: path outside home directory"));
    }
    
    std::fs::read_to_string(resolved).map_err(|e| e.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    
    // Tauri command logic can be extracted to pure functions for testing
    fn validate_path(file_path: &str, allowed_root: &str) -> Result<PathBuf, String> {
        let path = PathBuf::from(file_path);
        let allowed = PathBuf::from(allowed_root);
        
        if !path.starts_with(&allowed) {
            return Err(format!("SecurityError: path outside allowed root"));
        }
        Ok(path)
    }
    
    #[test]
    fn rejects_path_outside_home() {
        let result = validate_path("/etc/passwd", "/home/user");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("SecurityError"));
    }
    
    #[test]
    fn accepts_path_within_home() {
        let result = validate_path("/home/user/documents/file.txt", "/home/user");
        assert!(result.is_ok());
    }
}

For the frontend JavaScript wrapper:

// src/lib/file-api.ts
export interface FileAPI {
  readFile(path: string): Promise<string>;
  writeFile(path: string, content: string): Promise<void>;
}

export function createFileAPI(): FileAPI {
  return {
    async readFile(path: string): Promise<string> {
      return window.__TAURI__.invoke('read_file', { filePath: path });
    },
    async writeFile(path: string, content: string): Promise<void> {
      return window.__TAURI__.invoke('write_file', { filePath: path, content });
    },
  };
}
// src/lib/file-api.test.ts
import { createFileAPI } from './file-api';

const mockInvoke = jest.fn();
Object.defineProperty(window, '__TAURI__', {
  value: { invoke: mockInvoke },
  writable: true,
});

describe('FileAPI', () => {
  let api: ReturnType<typeof createFileAPI>;

  beforeEach(() => {
    jest.clearAllMocks();
    api = createFileAPI();
  });

  it('calls invoke with correct channel and arguments', async () => {
    mockInvoke.mockResolvedValueOnce('file content');
    
    const result = await api.readFile('/home/user/file.txt');
    
    expect(mockInvoke).toHaveBeenCalledWith('read_file', {
      filePath: '/home/user/file.txt',
    });
    expect(result).toBe('file content');
  });

  it('propagates invoke errors', async () => {
    mockInvoke.mockRejectedValueOnce('SecurityError: path outside home directory');
    
    await expect(api.readFile('/etc/passwd')).rejects.toBe(
      'SecurityError: path outside home directory'
    );
  });
});

Testing IPC Security Contracts

Security testing of IPC should be systematic. Define your security contract as tests:

// tests/security/ipc-security.test.ts
describe('IPC Security Contract', () => {
  const handler = new FileHandler('/home/user');

  const pathTraversalAttempts = [
    '/home/user/../../etc/passwd',
    '/home/user/../../../root/.ssh/id_rsa',
    '/home/user/docs/../../../../etc/shadow',
    '/../etc/passwd',
    '/home/user/%2e%2e/etc/passwd',
  ];

  pathTraversalAttempts.forEach(maliciousPath => {
    it(`rejects path traversal: ${maliciousPath}`, async () => {
      await expect(
        handler.readFile({ filePath: maliciousPath })
      ).rejects.toThrow('SecurityError');
    });
  });

  it('does not expose file system errors that leak path information', async () => {
    (fs.stat as jest.Mock).mockRejectedValueOnce(
      Object.assign(new Error('EACCES: permission denied, stat \'/home/user/secret.txt\''), {
        code: 'EACCES',
      })
    );

    try {
      await handler.readFile({ filePath: '/home/user/secret.txt' });
      fail('should have thrown');
    } catch (e: any) {
      // Error message should not contain system paths
      expect(e.message).not.toContain('/home/user');
    }
  });
});

Integration Testing IPC End-to-End

For true integration tests that exercise the full IPC pipeline in Electron, use Playwright:

// tests/e2e/ipc-integration.spec.ts
import { test, expect, _electron as electron } from '@playwright/test';

test('file:read IPC round-trip', async () => {
  const app = await electron.launch({ args: ['main.js'] });
  const page = await app.firstWindow();
  
  // Write a test fixture
  await app.evaluate(async () => {
    const fs = require('fs');
    fs.writeFileSync('/tmp/e2e-test-fixture.txt', 'expected content', 'utf-8');
  });
  
  // Trigger the read from the renderer
  const result = await page.evaluate(async () => {
    return window.electron.invoke('file:read', {
      filePath: '/tmp/e2e-test-fixture.txt',
    });
  });
  
  expect(result.content).toBe('expected content');
  
  await app.close();
});

Conclusion

IPC testing has three layers, each with distinct value. Unit tests on handler logic catch logic bugs and security issues cheaply. Registration tests verify channel names and error propagation. Integration tests confirm the full pipeline works with real OS interactions. Security contract tests make your attack surface explicit and permanently machine-checked. Together they give you coverage that catches the classes of bugs that are hardest to reproduce in manual testing — path traversal, silent serialization failures, and race conditions in shared state.

Read more

Start now free