Mocking the Node fs Module With memfs

Code that reads and writes files is awkward to unit test against a real disk: tests leave debris, parallel workers collide on paths, and every case needs a fixture directory on disk that someone has to maintain. memfs replaces the file system with an in-memory volume that implements the same API, so a test can declare a directory tree as a plain object, run the code, and inspect what it wrote — all in memory, all discarded afterwards. This guide covers wiring memfs into Vitest for both the callback and promise APIs, seeding and asserting on volumes, handling code that also needs a few real files, and the specific behaviours where an in-memory volume and a real disk disagree. It targets Vitest 2.x and memfs 4.x, and sits under file system and process mocking.

Root Cause Analysis

Real-disk unit tests fail in three characteristic ways. They leak: a test that writes ./out/report.json and then fails before cleaning up leaves a file that changes the next run’s behaviour. They collide: two workers writing to the same path produce corrupted output or spurious existence checks. And they are slow to set up, because every case needs its fixture directory created, populated and removed, which in practice means cases get skipped.

An in-memory volume removes all three. Each worker has its own process and therefore its own volume, so collisions are impossible. The volume is reset before each test, so leaks cannot cross test boundaries. And a fixture tree becomes a literal object in the test, which costs nothing to vary.

The trade-off is fidelity. memfs is a reimplementation of Node’s file-system semantics, and while it is thorough, it does not consult an operating system — so permissions, symbolic-link resolution, case sensitivity and the atomicity of renames follow memfs’s rules rather than the platform’s. For the logic of most file-handling code this does not matter; for code whose correctness depends on those behaviours, a real temporary directory is the right tool.

Swapping the file system module for an in-memory volume The code under test imports node:fs as usual; in tests, Vitest resolves that import to the memfs implementation, which reads and writes a volume held in the worker's memory and reset before each test. code under test import fs from 'node:fs' production the real disk tests vi.mock → memfs in-memory volume reset before each test
The code under test is unchanged; only what its import resolves to differs between production and tests.

Reproducible Setup

Install memfs and create a small module that registers the mocks, so every file-system test opts in with one import rather than repeating the wiring.

npm install -D memfs
// test/memfs.ts
import { vi, beforeEach } from 'vitest';
import { fs, vol } from 'memfs';

vi.mock('node:fs', async () => {
  const memfs = await vi.importActual<typeof import('memfs')>('memfs');
  return { default: memfs.fs, ...memfs.fs };
});

vi.mock('node:fs/promises', async () => {
  const memfs = await vi.importActual<typeof import('memfs')>('memfs');
  return { default: memfs.fs.promises, ...memfs.fs.promises };
});

beforeEach(() => vol.reset());

export { vol, fs };
// src/reports/write-report.ts — the code under test
import { mkdir, writeFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';

export async function writeReport(dir: string, name: string, rows: Array<Record<string, unknown>>) {
  await mkdir(dir, { recursive: true });
  const existing = await readdir(dir);
  const file = existing.includes(`${name}.json`) ? `${name}-${existing.length}.json` : `${name}.json`;
  await writeFile(join(dir, file), JSON.stringify(rows, null, 2));
  return file;
}

Implementation

Step 1 — Import the helper before the code under test. The mock must be registered before anything imports node:fs, and importing the helper first guarantees that order within a file.

// src/reports/write-report.test.ts
import { test, expect } from 'vitest';
import { vol } from '../../test/memfs';
import { writeReport } from './write-report';

Step 2 — Seed the volume as a plain object. vol.fromJSON takes a map of paths to contents and creates every intermediate directory, which makes each case’s starting state visible in the test itself.

test('writes a new report into an empty directory', async () => {
  vol.fromJSON({}, '/data');
  const file = await writeReport('/data/reports', 'weekly', [{ id: 1 }]);
  expect(file).toBe('weekly.json');
});

test('does not overwrite an existing report', async () => {
  vol.fromJSON({ '/data/reports/weekly.json': '[]' });
  const file = await writeReport('/data/reports', 'weekly', [{ id: 2 }]);
  expect(file).toBe('weekly-1.json');
});

Step 3 — Assert on the resulting volume, not on intermediate calls. vol.toJSON returns every file and its contents, which is a far more robust assertion than checking which functions were called in which order.

test('produces the expected tree', async () => {
  vol.fromJSON({ '/data/reports/weekly.json': '[]' });
  await writeReport('/data/reports', 'weekly', [{ id: 2 }]);

  expect(vol.toJSON()).toEqual({
    '/data/reports/weekly.json': '[]',
    '/data/reports/weekly-1.json': JSON.stringify([{ id: 2 }], null, 2),
  });
});

Step 4 — Simulate failures the real disk rarely produces on demand. Permission errors and full disks are hard to reproduce reliably with a real file system; with a mocked module, a spy can make any call fail with the exact error code the code must handle.

import { vi } from 'vitest';
import * as fsp from 'node:fs/promises';

test('reports a clear error when the directory is not writable', async () => {
  const eacces = Object.assign(new Error('permission denied'), { code: 'EACCES' });
  vi.spyOn(fsp, 'writeFile').mockRejectedValueOnce(eacces);

  await expect(writeReport('/data/reports', 'weekly', [])).rejects.toThrow(/permission denied/);
});
Asserting on the final volume instead of on calls Asserting that writeFile was called with particular arguments breaks when the implementation switches to a stream or a rename, while asserting on the volume's final contents passes for any implementation that produces the right files. assert on calls writeFile called with (path, json) breaks on a switch to streams or to write-then-rename tests the how, not the what assert on the volume vol.toJSON() equals the tree passes for any implementation that produces the right files tests the outcome
The volume is observable state, which is what makes memfs better than a hand-rolled fs mock.

Step 5 — Mix in a real file when the code needs one. Some code reads a real template or schema shipped with the package. Load it through the real module before seeding, then place it into the volume.

import { readFileSync } from 'node:fs';   // resolved to memfs in this file…

test('renders using the shipped template', async () => {
  const actual = await vi.importActual<typeof import('node:fs')>('node:fs');   // …so ask for the real one
  const template = actual.readFileSync(new URL('../templates/report.hbs', import.meta.url), 'utf8');
  vol.fromJSON({ '/app/templates/report.hbs': template });
  // …
});

Step 6 — Keep the mock scoped to the files that need it. Registering the memfs mock in a global setup file also replaces the file system for snapshot serialisers, coverage and some reporters. Import the helper per test file instead.

A word on paths, because they cause more confusion than anything else in memfs tests. The volume has no notion of the host’s current directory, so a relative path in the code under test resolves against process.cwd() — a real host path — and then looks for it in the volume, where it usually does not exist. Either pass absolute paths into the code, or seed the volume with a working directory argument that matches process.cwd(), so relative paths land where the fixture put them.

Verification

Confirm the mock is active by checking that nothing reached the real disk. A path that exists only in the volume must not exist on the host after the run.

test('writes nothing to the real disk', async () => {
  await writeReport('/definitely/not/on/the/host', 'x', []);
  const real = await vi.importActual<typeof import('node:fs')>('node:fs');
  expect(real.existsSync('/definitely/not/on/the/host')).toBe(false);
});

Then confirm isolation between tests by running the file in shuffled order; since the volume is reset before each test, every seed must pass.

npx vitest run src/reports --sequence.shuffle --sequence.seed=7
# ✓ 5 passed

Finally, run one representative case against a real temporary directory. If the in-memory and real results differ, you have found a behaviour your code depends on that the fake does not model, and that case belongs at the integration tier.

Where an in-memory volume and a real disk can disagree Permissions, symbolic-link resolution, case sensitivity and the atomicity of rename follow memfs's own rules rather than the operating system's, so code whose correctness depends on them needs a real temporary directory. permissions no real user or group checks symlinks resolution follows memfs's own rules case always sensitive, unlike macOS atomic rename no crash-safety semantics to test
If correctness depends on any of these, the test belongs on a real disk.

Troubleshooting

Symptom: the code under test still touches the real disk. Diagnosis: it imports fs rather than node:fs, or fs/promises rather than node:fs/promises, and only one specifier is mocked. Fix: mock both forms of each module, or normalise the codebase on the node: prefix, which is also clearer.

Symptom: ENOENT for a directory the test created. Diagnosis: vol.fromJSON with an empty object creates nothing, so the parent directory does not exist. Fix: pass a working directory as the second argument, or include at least one file under the directory you need.

Symptom: a third-party library ignores the mock. Diagnosis: the library is pre-bundled and captured its own reference to fs, or it uses a native binding. Fix: add it to Vitest’s server.deps.inline so it is transformed and sees the mock, or move that test to the integration tier with a real temporary directory.

Symptom: memory grows steadily through the run. Diagnosis: volumes are not reset, or a test loads a large fixture per case. Fix: reset in beforeEach, as the helper does, and keep in-memory fixtures small — realistic volume belongs on a real disk.

FAQ

Should I use memfs or mock-fs?

memfs is the better fit with Vitest because it provides a real module you can substitute with vi.mock, rather than patching Node’s internals at runtime. mock-fs patches the binding layer, which interacts poorly with test runners that themselves read files, and it is less actively maintained.

Can I use memfs with Jest?

Yes — jest.mock('fs', () => require('memfs').fs) follows the same pattern. The main difference is that Jest’s module registry and ESM handling make the promise-based API slightly more awkward to mock; the approach in this guide carries across with minor syntax changes.

Is it better to inject a file-system interface instead?

Where you control the design, yes: a function that accepts an object with readFile and writeFile is testable with a plain fake and needs no module mocking at all. memfs is the right tool when the code already imports the module directly and refactoring it would be disproportionate.

How do I test a file watcher?

Not with memfs alone, since watchers depend on operating-system notifications. Use a real temporary directory and write to it from the test, or put the watcher behind an event-emitter seam and test the reaction logic separately from the watching — the second is faster and far less flaky.