# Modly Testing Strategies: How to Test Electron Preload APIs with Node's Native Runner

> Discover Modly's testing strategies. Learn how to test Electron preload APIs using Node's native runner and mock the IPC bridge for efficient, isolated testing without a full Electron instance.

- Repository: [lightningpixel/modly](https://github.com/lightningpixel/modly)
- Tags: testing
- Published: 2026-08-20

---

**Modly uses Node's native test runner (`node:test`) with co-located `*.test.ts` files, mocking the Electron IPC bridge to test preload APIs in pure Node without launching a full Electron instance.**

Electron preload APIs present a unique testing challenge: they run in a privileged renderer context and depend on Electron's `ipcRenderer` and `webFrame` APIs. This article examines how the Modly project solves this with a lightweight, dependency-free approach that keeps tests fast and deterministic. All testing patterns described here are implemented in the `lightningpixel/modly` repository.

## Core Testing Architecture

Modly's testing strategy prioritizes **minimal dependencies** and **fast feedback loops**. Rather than importing Jest, Vitest, or Mocha, the project relies entirely on Node.js built-ins.

### Test Runner: Node Native

The [`package.json`](https://github.com/lightningpixel/modly/blob/main/package.json) test script invokes Node directly:

```bash
node --test

```

This automatically discovers all files matching `*.test.*` patterns across the codebase. No configuration files, no global setup, no extra packages to install.

### File Organization: Co-Location

Every test file lives adjacent to its implementation:

```

electron/preload/
├── electron-api.ts
├── index.ts
└── artifact-registry-preload.test.ts

electron/main/
├── extension-path-guard.ts
├── extension-path-guard.test.ts
├── extension-install-utils.ts
└── extension-install-utils.test.mjs

```

This convention makes the relationship between implementation and verification immediately obvious. When you modify [`electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron-api.ts), the corresponding test file is right there.

### Assertions: Node Assert Module

All tests import from `node:assert/strict`:

```typescript
import assert from 'node:assert/strict'

```

This provides `assert.equal`, `assert.deepEqual`, `assert.throws`, and other familiar methods without pulling in Chai or similar libraries.

## Testing Electron Preload APIs

The preload layer in [`electron/preload/electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts) exposes a scoped API to the renderer process. Testing this requires simulating Electron's restricted environment.

### The Challenge: Electron Dependencies

Preload scripts have access to:

- `ipcRenderer` – for async IPC with the main process
- `webFrame` – for renderer-level operations like zoom control

These APIs are only available inside an actual Electron renderer. Modly's solution: **inject mock implementations through a factory function**.

### Factory Pattern for Testability

The `createElectronApi` function accepts its dependencies as parameters:

```typescript
// Simplified signature from electron-api.ts
function createElectronApi(
  ipcRenderer: IpcRendererLike,
  webFrame: WebFrameLike
): ElectronApi

```

In production, the real Electron objects are passed in. In tests, a stub implementation records all interactions.

### Recording IPC Calls

The test suite in [`electron/preload/artifact-registry-preload.test.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/artifact-registry-preload.test.ts) creates a spy that captures every `invoke` call:

```typescript
import assert from 'node:assert/strict'
import test from 'node:test'

import { createElectronApi } from './electron-api.ts'

test('preload exposes scoped workspace library list/read/open methods', async () => {
  const calls: Array<{ channel: string; payload?: unknown }> = []
  const api = createElectronApi(
    {
      invoke: async (channel: string, payload?: unknown) => {
        calls.push({ channel, payload })
        return { success: true, entries: [] }
      },
      send: () => undefined,
      on: () => undefined,
      removeAllListeners: () => undefined,
    },
    { setZoomFactor: () => undefined }
  )

  await api.workspace.library.list()
  await api.workspace.library.read({
    workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json',
    sourceWorkspacePath: 'Workflows/checkpoints/hero.glb',
  })
  await api.workspace.library.open({
    workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json',
    sourceWorkspacePath: 'Workflows/checkpoints/hero.glb',
  })

  assert.deepEqual(calls, [
    { channel: 'workspace:library:list', payload: undefined },
    {
      channel: 'workspace:library:read',
      payload: {
        workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json',
        sourceWorkspacePath: 'Workflows/checkpoints/hero.glb',
      },
    },
    {
      channel: 'workspace:library:open',
      payload: {
        workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json',
        sourceWorkspacePath: 'Workflows/checkpoints/hero.glb',
      },
    },
  ])
})

```

### What This Verifies

| Contract Aspect | Test Assertion |
|-----------------|--------------|
| **Channel naming** | `workspace:library:list`, `workspace:library:read`, `workspace:library:open` |
| **Payload structure** | Objects with `workspacePath` and `sourceWorkspacePath` fields |
| **Return value propagation** | Promise resolves with stub's `{ success: true, entries: [] }` |
| **Method isolation** | Each call is independent, no shared state leakage |

The test runs in milliseconds because no Electron instance is launched. The entire preload contract is validated in pure Node.js.

## Testing Main Process Modules

The same patterns extend to main-process utilities.

### Extension Path Guards

[`electron/main/extension-path-guard.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/extension-path-guard.ts) validates that extension operations stay within permitted directories. Its test file [`extension-path-guard.test.ts`](https://github.com/lightningpixel/modly/blob/main/extension-path-guard.test.ts) stubs the file system and asserts on path traversal prevention.

### Installer Utilities

[`electron/main/extension-install-utils.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/extension-install-utils.ts) handles extension package extraction. The corresponding `extension-install-utils.test.mjs` uses the same `node:test` and `node:assert` pattern, mocking archive operations and verifying cleanup behavior.

## CI Integration

The repository's [`.github/workflows/ci.yml`](https://github.com/lightningpixel/modly/blob/main/.github/workflows/ci.yml) runs `npm test` on every push. Because tests don't require Electron binaries, the CI pipeline remains lightweight and fast. Regression detection happens within seconds, not minutes.

## Summary

- **Modly testing strategies** rely on Node's native `node:test` runner—no external test framework required
- **Electron preload API testing** uses factory injection to mock `ipcRenderer` and `webFrame`, enabling pure-Node verification
- Test files follow `*.test.ts`/`*.test.mjs` naming and co-locate with source code
- The `createElectronApi` factory captures IPC calls to verify channel names and payload contracts
- All tests run via `node --test`, executed in CI on every push for rapid regression detection

## Frequently Asked Questions

### How does Modly test code that depends on Electron APIs without running Electron?

Modly's **factory pattern** exposes dependencies as parameters to `createElectronApi`. Tests pass stub objects that implement the same interface as `ipcRenderer` and `webFrame`. This allows the preload layer to execute in a pure Node environment while still verifying that it makes the correct IPC calls with the expected payloads.

### Why doesn't Modly use Jest or Vitest for testing?

The project prioritizes **dependency minimalism**. Node's built-in test runner (stable since Node 18) provides sufficient features: test organization, async/await support, and native assertion methods. Avoiding external test frameworks reduces `node_modules` size, installation time, and long-term maintenance burden from dependency updates.

### What naming convention should I follow when adding tests to Modly?

Name test files with the [`.test.ts`](https://github.com/lightningpixel/modly/blob/main/.test.ts) or `.test.mjs` suffix and place them in the same directory as the source file they verify. The `node --test` flag automatically discovers these files. For example, [`electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron-api.ts) pairs with [`artifact-registry-preload.test.ts`](https://github.com/lightningpixel/modly/blob/main/artifact-registry-preload.test.ts) in `electron/preload/`.

### How does Modly ensure preload tests stay synchronized with the main process IPC handlers?

The **channel contract** is the bridge. Preload tests assert that methods invoke specific channels like `workspace:library:list`. Main process tests verify that handlers exist for these same channels. While Modly doesn't currently use integration tests spanning both processes, the shared channel string constants (typically defined alongside the API types) keep both sides aligned.