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.
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/);
});
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.
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.
Related
- Back to File System & Process Mocking
- Mocking Node built-in modules in Vitest — the general mechanics behind this mock.
- Testing CLI tools that spawn child processes — the other half of most file-handling tools.
- Partial mocking with vi.importActual — reaching the real module from inside a mocked file.