File System & Process Mocking
Node code that touches the operating system is some of the hardest code in a JavaScript codebase to test well. A build script reads a directory tree and writes artifacts; a CLI parses arguments, spawns git, and exits with a status code; a server reads its configuration from environment variables at import time. Each of these reaches outside the process into state that other tests share, that differs between a laptop and a CI runner, and that can leave debris behind when a test fails halfway. This topic belongs to advanced mocking and service isolation patterns and covers the four operating-system surfaces that JavaScript tests most often need to control — the file system, child processes, environment variables and the standard streams — with a consistent rule for when to fake each one and when a real but isolated resource is the better choice.
Architectural Scope & Boundaries
The code this topic concerns is Node code with side effects outside the JavaScript heap: command-line tools, build and code-generation scripts, file importers and exporters, deployment helpers, and the configuration layer of any server. Browser code rarely touches these surfaces, and when it does — file uploads, downloads — the relevant techniques live in DOM and browser API mocking instead.
The most important boundary is between faking a surface and isolating a real one. An in-memory file system is fast, deterministic and invisible to other tests, but it is a reimplementation, and it can differ from a real disk in ways that matter — permissions, symbolic links, case sensitivity, atomic renames. A real temporary directory unique to each test is slower but exercises the operating system’s actual behaviour. The rule this topic follows is to fake at the unit tier, where speed and determinism dominate, and to use real isolated resources at the integration tier, where fidelity does.
The same split applies to processes. A unit test should not spawn git; it should call the code that decides what arguments to pass and assert on them, with the spawn itself behind a seam. An integration test for a CLI should spawn the real CLI binary and observe its real output and exit code, because the argument parser, the stream buffering and the exit behaviour are exactly what the integration tier exists to check.
What this topic excludes: testing the operating system itself, testing Node’s standard library, and anything requiring elevated privileges. If a test needs root, a real device or a specific kernel feature, it belongs in a dedicated environment rather than the ordinary suite.
Environment variables deserve a separate note because they are the most frequently leaked state in JavaScript test suites. Unlike the file system, which tests at least know is shared, process.env looks like an ordinary object, and assigning to it feels local. It is not: the assignment persists for every subsequent test in the worker, and configuration read at import time captures whatever value happened to be present when the module first loaded.
The standard streams complete the set, and they have a property the other three lack: the test runner itself writes to them. A test that replaces process.stdout.write and fails before restoring it can swallow the reporter’s own output for the rest of the run, which produces the baffling experience of a suite that appears to hang or to report nothing. Stream capture therefore has to be the most carefully scoped of the four, restored in a hook that runs whatever the test’s outcome.
A general principle underlies all four surfaces, and it is worth stating plainly because it determines how much of this topic you need. Code that receives its dependencies — a file-system interface, a runner, a configuration object, an output stream — is easy to test with plain fakes and needs almost none of the module-replacement machinery below. Code that reaches for globals directly is where that machinery earns its keep. Where you control the design, the first style is cheaper to test; where you are testing existing code, the techniques here let you proceed without a rewrite.
Prerequisites
Step-by-Step Implementation
Step 1 — Fake the file system at the unit tier with memfs. Vitest can replace node:fs and node:fs/promises with an in-memory volume, so code under test reads and writes files that exist only for the duration of the test.
// test/fs.ts — shared setup for tests that fake the file system
import { vi, beforeEach } from 'vitest';
import { fs, vol } from 'memfs';
vi.mock('node:fs', () => ({ default: fs, ...fs }));
vi.mock('node:fs/promises', () => ({ default: fs.promises, ...fs.promises }));
beforeEach(() => vol.reset());
export { vol };
// src/config/load.test.ts
import { test, expect } from 'vitest';
import { vol } from '../../test/fs';
import { loadConfig } from './load';
test('merges the local override on top of the defaults', async () => {
vol.fromJSON({
'/app/config/default.json': JSON.stringify({ port: 3000, logLevel: 'info' }),
'/app/config/local.json': JSON.stringify({ logLevel: 'debug' }),
});
expect(await loadConfig('/app/config')).toEqual({ port: 3000, logLevel: 'debug' });
});
Two details make this reliable. The mock must cover both the callback and promise forms of the module, because production code frequently mixes them. And the volume must be reset before each test rather than after, so a test that fails mid-way never leaves files for the next one — the reset happens regardless of what came before.
Step 2 — Use a real temporary directory at the integration tier. When behaviour depends on the operating system — permissions, renames, watchers — create a unique directory per test and remove it afterwards.
// test/tmp.ts
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach } from 'vitest';
const created: string[] = [];
export async function tempDir(prefix = 'test-') {
const dir = await mkdtemp(join(tmpdir(), prefix));
created.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(created.splice(0).map((d) => rm(d, { recursive: true, force: true })));
});
The prefix is more useful than it looks. When a test fails in a way that skips cleanup, or a CI job is cancelled mid-run, the leftover directories are identifiable by name, and a periodic sweep of anything with the prefix older than a day keeps a long-lived self-hosted runner from filling its disk.
Step 3 — Put child processes behind an injectable runner. Code that decides what to run is logic and deserves unit tests; the act of running it is an integration concern.
// src/git/runner.ts
import { execa } from 'execa';
export type Runner = (cmd: string, args: string[], opts?: { cwd?: string }) => Promise<{ stdout: string }>;
export const realRunner: Runner = (cmd, args, opts) => execa(cmd, args, opts);
// src/git/changed-files.ts
export async function changedFiles(base: string, run: Runner = realRunner) {
const { stdout } = await run('git', ['diff', '--name-only', `${base}...HEAD`]);
return stdout.split('\n').filter(Boolean);
}
// src/git/changed-files.test.ts
test('asks git for the diff against the merge base', async () => {
const run = vi.fn().mockResolvedValue({ stdout: 'src/a.ts\nsrc/b.ts\n' });
expect(await changedFiles('origin/main', run)).toEqual(['src/a.ts', 'src/b.ts']);
expect(run).toHaveBeenCalledWith('git', ['diff', '--name-only', 'origin/main...HEAD']);
});
The seam costs one optional parameter and pays for itself immediately: the unit test runs in microseconds, asserts on the exact arguments the code would have passed, and can simulate failures — a non-zero exit, a missing binary, garbled output — that are awkward to produce with a real process. The default value keeps production call sites unchanged.
Step 4 — Stub environment variables with automatic restoration. vi.stubEnv records the original value and unstubAllEnvs puts it back, which removes the most common source of cross-test leakage.
// vitest.config.ts
export default defineConfig({ test: { unstubEnvs: true } }); // restore after every test
// src/config/env.test.ts
test('uses the configured region', () => {
vi.stubEnv('AWS_REGION', 'eu-west-2');
expect(readRegion()).toBe('eu-west-2');
});
Step 5 — Capture output and exit status without ending the test run. A CLI that calls process.exit would terminate the worker; spy on it and make it throw instead, and capture what was written to the streams.
// test/process.ts
import { vi } from 'vitest';
export function captureProcess() {
const out: string[] = [];
const err: string[] = [];
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => (out.push(String(chunk)), true));
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => (err.push(String(chunk)), true));
const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
}) as never);
return { stdout: () => out.join(''), stderr: () => err.join(''), exit };
}
The helper deliberately makes process.exit throw rather than doing nothing. A no-op exit would let the code under test carry on past the point where it meant to stop, running cleanup or printing messages that never happen in reality. Throwing stops execution at the same place the real exit would, and the thrown message carries the exit code for the assertion.
Configuration Reference Table
| Setting | Type | Default | Effect |
|---|---|---|---|
test.environment |
enum | node |
Must be node for these tests; jsdom lacks process behaviour. |
test.unstubEnvs |
boolean | false |
Restores every vi.stubEnv after each test automatically. |
test.restoreMocks |
boolean | false |
Restores spies such as process.exit between tests. |
test.pool |
enum | forks |
Forks isolate process.chdir and globals better than threads. |
test.isolate |
boolean | true |
Fresh module registry per file, so import-time config is re-read. |
vol.fromJSON |
memfs | — | Populates the in-memory volume from a path-to-content map. |
mkdtemp prefix |
string | — | A recognisable prefix makes leaked directories easy to find. |
execa reject |
boolean | true |
Set false in CLI tests to assert on non-zero exits instead of catching. |
Setting unstubEnvs and restoreMocks globally is worth doing on day one even if only a handful of tests use these features. They cost nothing when unused, and they convert an entire category of intermittent failure — a stub or spy from one test surviving into another — into something that simply cannot happen.
Verification & Assertions
Verify isolation first, because it is the property most likely to be silently broken. Run the file-system tests in random order several times; any test that depends on files another test created will fail on some seed.
for seed in 1 2 3 4 5; do npx vitest run src --sequence.shuffle --sequence.seed=$seed --silent || echo "seed $seed failed"; done
Then verify the temporary directories are actually removed, since a failing test that skips cleanup leaves debris that can interfere with later runs on the same machine.
npx vitest run src/integration && ls "$(node -p 'require("os").tmpdir()')" | grep -c '^test-'
# 0
A useful supplementary check for the file-system fakes is to run the same small test against both implementations once. A helper that writes a file, renames it and reads it back, executed first against memfs and then against a real temporary directory, will surface any behaviour your code depends on that the fake does not reproduce — and it takes a minute to write.
Finally, verify the environment is clean between tests by asserting on it directly in a guard test that runs last, which catches any test that assigns to process.env rather than stubbing it.
Edge Cases & Failure Modes
Configuration captured at import time. A module that reads process.env.PORT into a constant at the top level captures whatever was present when the module first loaded, so stubbing the variable afterwards has no effect. Diagnose by stubbing and observing no change; fix by reading configuration through a function, or by resetting modules and re-importing after stubbing, as covered in isolating environment variables per test.
Mocks that apply to the test runner too. Replacing node:fs globally in a file also replaces it for anything else that file imports, including snapshot serialisers and some reporters. Scope the mock to the files that need it rather than to a global setup, so the runner’s own file access is unaffected.
Path separators and case sensitivity. memfs follows POSIX rules by default, so code that builds paths with string concatenation passes on Linux and fails on Windows, and code that assumes a case-insensitive file system passes on macOS and fails in a Linux container. Use node:path consistently and run at least one integration job on each target platform if you ship a CLI.
process.chdir across tests. Changing the working directory affects every subsequent test in the worker, and with thread-based pools it is not permitted at all. Pass a working directory explicitly into the code under test instead of changing the process’s own.
Large fixtures loaded into the in-memory volume. memfs holds everything in the heap, so a test that loads a realistic multi-megabyte directory tree into a volume per test can exhaust a worker’s memory in a long run. Keep in-memory fixtures small and synthetic, and move tests that genuinely need realistic volume to the integration tier where a real disk bears the load.
Unhandled writes after a test ends. A stream flushed asynchronously can write to stdout after the test has restored the spy, producing output in the middle of the reporter’s log or an assertion that sees nothing. Await the command’s completion, including stream draining, before asserting.
Performance & CI Impact
memfs-backed unit tests are among the fastest tests in any suite — no disk I/O, no process creation — and they run safely in parallel because each worker has its own volume. Integration tests with real temporary directories are slower by an order of magnitude but still quick on a local SSD; the dominant cost at that tier is process spawning, where each launch of a Node CLI costs tens to hundreds of milliseconds.
That spawn cost is the main reason to keep CLI integration tests few. Test argument parsing, validation and decision logic in-process at the unit tier, and reserve the spawned-binary tests for what only they can show: that the entry point wires everything together, that exit codes are correct, and that output reaches the right stream.
Parallelism needs one precaution. Tests that change the working directory or rely on a fixed port or path cannot run concurrently, and forks-based pools isolate more process state than threads do. If a subset of tests genuinely requires process-level isolation, give it its own project with pool: 'forks' rather than slowing the whole suite.
CI runners also differ from laptops in their temporary directories — smaller, sometimes on a RAM disk, sometimes shared between jobs on self-hosted machines. Prefixing temporary directories recognisably and cleaning them up unconditionally keeps a long-lived runner from filling up with the debris of failed runs.
Finally, measure before optimising. A suite with a few dozen spawned-binary tests often turns out to spend most of its time in one or two that start a heavyweight dependency — a bundler, a type checker — which can be replaced with a lighter fixture or moved to a nightly job. The per-file timings the runner already reports are enough to find them, and they are usually a far bigger win than any change to how the file system is faked.
In-Depth Guides
- Mocking the Node fs module with memfs — an in-memory volume per test, and where it diverges from a real disk.
- Testing CLI tools that spawn child processes — a runner seam for unit tests and the real binary for integration.
- Isolating environment variables per test — stubbing, restoring, and handling configuration read at import time.
- Asserting on stdout and process exit codes — capturing streams and exits without killing the worker.
Related
- Back to Advanced Mocking & Service Isolation Patterns
- Module and Dependency Mocking — the vi.mock mechanics this topic relies on.
- Mocking Node built-in modules in Vitest — the general case of which fs is one example.
- Eliminating test order dependence — why leaked process state produces flaky suites.
Mocking the Node fs Module With memfs
Replace node:fs with an in-memory volume in Vitest: seeding directory trees, asserting on written files, and where memfs differs from a real disk.
Testing CLI Tools That Spawn Child Processes
Split a Node CLI into testable decisions and a thin process layer: fake runners for argument logic, real spawns for wiring, deterministic fixtures.
Isolating Environment Variables per Test
Stop process.env leaking between tests: vi.stubEnv with automatic restore, re-importing config read at load time, and a typed config layer.
Asserting on stdout and Process Exit Codes
Test what a Node program prints and how it exits without killing the worker: injected streams, captured writes, exit spies and stdin input.