Mocking Node Built-In Modules in Vitest

Node’s built-in modules — fs, os, crypto, child_process, path — are where JavaScript code touches the machine, and they are where tests most often need to take control: a fixed hostname, a predictable UUID, a spawn that does not really spawn. Mocking them in Vitest is straightforward once three quirks are understood: built-ins can be imported with or without the node: prefix and each form needs its own mock; they expose both a default export and named exports that must stay consistent; and replacing the whole module breaks unrelated code that needed the real functions. This guide covers mocking built-ins correctly, partial mocks that replace one function and keep the rest, spying instead of mocking where that suffices, and the point at which dependency injection is simply the better design. It sits under module and dependency mocking.

Root Cause Analysis

The first trap is the specifier. import os from 'os' and import os from 'node:os' load the same module at runtime, but Vitest’s mock registry keys on the specifier string, so vi.mock('node:os') does not affect code that imports 'os'. Codebases mix the two freely, and dependencies choose their own, so a mock that works for your code can be silently bypassed by a library.

The second is the export shape. Built-ins are CommonJS modules exposed to ESM, so they have a default export — the whole module object — and named exports for each function. Code may use either: os.hostname() through the default, or hostname() as a named import. A mock factory that provides only one form breaks the code that uses the other, with an error that points at the call site rather than the mock.

The third is scope. vi.mock('node:crypto') with a factory that returns only randomUUID removes every other crypto function from every module in that test file — including the ones a hashing library, a JWT library or the test runner itself relies on. The result is failures far from the test, in code that was never meant to be mocked.

Two specifiers, one module, two mock registrations Code importing 'os' and code importing 'node:os' reach the same runtime module, but Vitest keys mocks by specifier, so mocking only one form leaves the other importing the real module. your code import 'node:os' a dependency import 'os' vi.mock('node:os') intercepted no mock for 'os' real module loads inconsistent two different hostnames
Mock both specifiers, or normalise on one, or the mock applies to half the code.

Reproducible Setup

A module that uses three built-ins, in the mixed styles real code tends to have.

// src/diagnostics/report.ts
import os from 'node:os';
import { randomUUID } from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const run = promisify(execFile);

export async function diagnosticsReport() {
  const { stdout } = await run('git', ['rev-parse', '--short', 'HEAD']);
  return {
    id: randomUUID(),
    host: os.hostname(),
    platform: os.platform(),
    cpus: os.cpus().length,
    commit: stdout.trim(),
  };
}

Implementation

Step 1 — Mock with importActual so unmocked functions stay real. Spread the real module, then override only what the test needs; both the default and the named exports come from the same object, so they stay consistent.

// src/diagnostics/report.test.ts
import { vi, test, expect } from 'vitest';

vi.mock('node:os', async (importOriginal) => {
  const actual = await importOriginal<typeof import('node:os')>();
  const mocked = { ...actual, hostname: () => 'test-host', platform: () => 'linux' as NodeJS.Platform };
  return { ...mocked, default: mocked };
});

Starting from importOriginal() is the single most important habit here. It means the mock is a small, visible diff against the real module — two functions changed, everything else genuine — rather than a hand-written replacement that silently lacks whatever the author did not think to include. The day a library upgrade starts calling a function the mock never provided, a partial mock keeps working; a hand-written one fails with an error that points nowhere near the cause.

Step 2 — Mock the bare specifier too, when dependencies use it. A tiny helper keeps the two registrations identical.

// test/mock-builtin.ts
import { vi } from 'vitest';

export function mockBuiltin<T extends object>(name: string, overrides: Partial<T>) {
  const factory = async (importOriginal: () => Promise<T>) => {
    const actual = await importOriginal();
    const mocked = { ...actual, ...overrides };
    return { ...mocked, default: mocked };
  };
  vi.doMock(`node:${name}`, factory as never);
  vi.doMock(name, factory as never);
}

vi.doMock is not hoisted, so it must run before the module under test is imported — typically followed by a dynamic await import(...) in the test.

Step 3 — Spy on a single function instead of mocking the module. For named functions on a module object, vi.spyOn replaces one function for one test and is restored automatically, with no factory at all.

import * as crypto from 'node:crypto';

test('uses a fresh UUID for each report', async () => {
  vi.spyOn(crypto, 'randomUUID').mockReturnValueOnce('00000000-0000-4000-8000-000000000001');
  const { diagnosticsReport } = await import('./report');
  expect((await diagnosticsReport()).id).toBe('00000000-0000-4000-8000-000000000001');
});

Step 4 — Mock child_process at the promisified boundary. Callback-style APIs wrapped with promisify are awkward to mock directly; mock the function to call its callback, which is what promisify expects.

vi.mock('node:child_process', async (importOriginal) => {
  const actual = await importOriginal<typeof import('node:child_process')>();
  const execFile = vi.fn((_cmd: string, _args: string[], cb: (e: Error | null, r: { stdout: string; stderr: string }) => void) =>
    cb(null, { stdout: 'a1b2c3d\n', stderr: '' }));
  const mocked = { ...actual, execFile };
  return { ...mocked, default: mocked };
});

test('includes the short commit hash', async () => {
  const { diagnosticsReport } = await import('./report');
  expect(await diagnosticsReport()).toMatchObject({ commit: 'a1b2c3d', host: 'test-host' });
});
Choosing between spying, partial mocking and injection Spy on one function for one test when the module exposes it as a property, use a partial mock with importActual when a named import must be replaced, and inject the dependency when the code is yours and the built-in is central to its behaviour. vi.spyOn one function, one test no factory, auto-restored the lightest option partial vi.mock named imports, whole file importActual + override keep default in sync inject your code, central dependency pass it as a parameter no mocking at all
Prefer the lightest option that works; module mocks are the heaviest and the most likely to surprise.

Step 5 — Prefer injection for code you own. When a built-in is central to a function’s behaviour — the clock, the random source, the process runner — passing it in removes the mocking entirely and makes the dependency visible in the signature.

// src/diagnostics/report.ts — injectable version
type Env = { hostname: () => string; uuid: () => string; git: () => Promise<string> };

export async function diagnosticsReport(env: Env = realEnv) {
  return { id: env.uuid(), host: env.hostname(), commit: (await env.git()).trim() };
}

// test — no vi.mock anywhere
const report = await diagnosticsReport({ hostname: () => 'h', uuid: () => 'u', git: async () => 'abc\n' });
expect(report).toEqual({ id: 'u', host: 'h', commit: 'abc' });

Step 6 — Never mock node:path. It is pure, deterministic and fast; mocking it only introduces the chance of a mismatch between your fake joins and the real ones on another platform.

A rule of thumb follows from all of this. Reach for a module mock only when the built-in is incidental to the code under test and cannot easily be passed in — a library deep in the call stack reading the hostname, say. When the built-in is the point of the function, as a process runner is for a deploy script, the function should receive it as a parameter, and the tests become ordinary calls with ordinary fakes. Most codebases that find built-in mocking painful are mocking in the second situation, where injection would have removed the problem.

Verification

Confirm the partial mock left the rest of the module intact: a function you did not override should behave as the real one does.

test('unmocked os functions are still real', async () => {
  const os = await import('node:os');
  expect(os.hostname()).toBe('test-host');           // overridden
  expect(os.cpus().length).toBeGreaterThan(0);       // real
});

Then confirm both specifiers are covered by importing the module each way in a test and checking they return the same overridden value. A mismatch there means a dependency using the other specifier is seeing the real implementation.

Keeping default and named exports consistent A mock that sets only named exports breaks code using the default import, and one that sets only the default breaks named imports; building one mocked object and exposing it as both keeps every call style working. named only os.hostname() breaks default is undefined default only { hostname } breaks named is undefined one object, both { ...mocked, default: mocked }
Build one mocked object and expose it both ways; the two import styles then cannot disagree.

Troubleshooting

Symptom: the mock works in your module but not in a dependency. Diagnosis: the dependency uses the other specifier, or it is pre-bundled and captured the real module before your mock was registered. Fix: mock both specifiers, and add the dependency to server.deps.inline so Vitest transforms it and applies the mock.

Symptom: default is not a function or hostname is not a function. Diagnosis: the factory provided only named exports or only a default. Fix: build one object and return it as both, as the helper in Step 2 does.

Symptom: unrelated tests fail after adding a built-in mock. Diagnosis: the factory replaced the whole module rather than spreading the actual one, so other functions vanished. Fix: always start from importOriginal() and override selectively.

Symptom: the mock factory cannot see a variable defined in the test file. Diagnosis: vi.mock is hoisted above the variable declaration. Fix: create the variable with vi.hoisted, or use vi.doMock with a dynamic import — the rules are covered in avoiding vi.mock hoisting pitfalls.

FAQ

Should I normalise the codebase on the node: prefix?

Yes — it is clearer, it cannot be shadowed by a package of the same name, and a lint rule can enforce it. It does not remove the need to mock both forms if dependencies use the bare specifier, but it removes the problem from your own code.

Is it safe to mock node:fs this way?

For narrow overrides, yes. For replacing the whole file system, an in-memory implementation is a better fit than hand-mocking dozens of functions — see mocking the Node fs module with memfs.

How do I mock crypto.randomUUID for the whole suite?

Prefer a spy in each test that needs a fixed value, restored automatically. A global override makes every generated identifier identical, which hides bugs where two entities are accidentally given the same id. Where many tests need determinism, a seeded generator is kinder than a constant.

Do these techniques work in Jest?

The ideas carry over; the syntax differs. Jest’s jest.mock('os', …) with jest.requireActual mirrors importOriginal, and the default-versus-named consistency problem is identical under Jest’s ESM support.