Testing Node.js Code That Reads Files: Three Approaches, Three Seams
Source: hackernews
The Setup
Consider a function that reads a config file from disk:
import { readFileSync } from 'node:fs';
export function loadConfig(path) {
const raw = readFileSync(path, 'utf8');
return JSON.parse(raw);
}
Writing a test for this looks straightforward until you decide you do not want tests touching the real filesystem. You want fake config content, hermetic tests, and nothing that breaks on a CI machine without the right files in place. In most ecosystems there is a clean path here. In Node.js, you end up picking between three approaches, and each one has a seam.
Approach 1: jest.mock(‘fs’)
The first thing developers try is mocking the fs module directly:
jest.mock('fs', () => ({
readFileSync: (path) => {
if (path === '/config.json') return '{"port":3000}';
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
}
}));
This covers code that imports fs as a CommonJS module. It does not cover code that uses the node: URL prefix, which is the canonical form in modern Node.js:
// This import bypasses jest.mock('fs') entirely
import { readFileSync } from 'node:fs';
Jest resolves 'fs' and 'node:fs' as separate module cache entries, so mocking one leaves the other untouched. You need both:
jest.mock('fs', () => mockFs);
jest.mock('node:fs', () => mockFs);
If any dependency in your tree migrated to the node: prefix, your existing mocks stopped working silently. This is a recurring source of confusing test failures when upgrading packages, not a theoretical concern.
The seam here is module identity. Because fs is a concrete module and not an interface, mocking requires replacing one or more module cache entries. The number of entries grows as the ecosystem adopts new import conventions.
Approach 2: memfs
The more ergonomic option is memfs, a complete in-memory implementation of the Node.js fs API. Its vol.fromJSON() method lets you declare a fake filesystem declaratively:
import { vol, fs as memFs } from 'memfs';
jest.mock('fs', () => memFs);
jest.mock('node:fs', () => memFs);
jest.mock('node:fs/promises', () => memFs.promises);
beforeEach(() => {
vol.fromJSON({
'/config.json': JSON.stringify({ port: 3000 }),
'/data/users.json': JSON.stringify([]),
});
});
afterEach(() => vol.reset());
This is considerably better than the manual mock. memfs is well-maintained and its API surface matches Node’s faithfully, including streams, fs.promises, and directory operations. The Jest module identity problem persists but is manageable if you remember to mock all three entry points.
The harder problem is native addons. If your code uses a native module, better-sqlite3, sharp, bcrypt, or any *.node binary, that module calls into the OS filesystem via C or C++ without going through Node’s fs module. The JavaScript-level mock is invisible to it. Tests either hit the real disk or fail with an error that has nothing to do with what you were testing.
This is not a limitation of memfs specifically. It is a consequence of where the hook lives. JavaScript-land module replacement can only intercept callers that go through the JavaScript module system. Code that talks to libuv directly, either from a native addon or from Node’s own C++ bindings in certain paths, bypasses the hook entirely.
Approach 3: tmpdir
The third approach abandons mocking entirely and uses a real temporary directory:
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
let testDir;
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), 'myapp-test-'));
writeFileSync(join(testDir, 'config.json'), JSON.stringify({ port: 3000 }));
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
This is hermetic: each test gets a fresh directory that is cleaned up afterward. It works with native addons because everything goes through real I/O. It has no module identity seams.
The costs are real disk I/O in every test run, slower suites at scale, and cleanup logic that is easy to miss when a test throws early. None of these are fatal, but they represent overhead that other ecosystems avoid.
Why All Three Approaches Have This Problem
The common root is that fs in Node.js is a concrete module with no interface definition and no injection point below the JavaScript layer. There is no fs.Provider or fs.Backend protocol you can implement and register. There is no hook that runs before a readFile call the way module.register() runs before module resolution.
Node.js spent years building exactly this kind of extensibility for module loading. The evolution went from require.extensions (fragile, CJS-only) through --experimental-loader (powerful but awkward, running in a separate thread) to module.register(), which stabilized in Node.js v20.6.0 and v18.19.0. It provides composable hooks for resolve and load that work for both ESM and CommonJS and compose cleanly when multiple packages register hooks:
import { register } from 'node:module';
register('./my-loader.js', import.meta.url);
Matteo Collina’s article at Platformatic is arguing for the same capability at the filesystem level, and the testing case is one of the clearest illustrations of why it matters in daily development. The Node.js Single Executable Applications feature lands in the same gap: assets embedded with node:sea’s getAsset() API are not visible to code that calls fs.readFileSync(), which means third-party dependencies cannot transparently reach embedded files without modification.
What Other Ecosystems Provide
Go 1.16 added fstest.MapFS alongside the io/fs interface and the embed package. Testing a function that accepts fs.FS requires no mocking framework:
func TestLoadConfig(t *testing.T) {
fsys := fstest.MapFS{
"config.json": {Data: []byte(`{"port":3000}`)},
}
cfg, err := loadConfig(fsys, "config.json")
// ...
}
Because the standard library’s HTTP server, template parsers, and walk utilities all accept fs.FS, any function written against the interface is immediately testable without patching global state. The design choice to define the interface before building the implementations is what makes this composable.
Python’s pyfakefs takes a different route: it patches C-level file I/O functions using CPython’s import machinery, which means it intercepts at a lower level than Python module replacement and works even for C extension modules that call fopen through CPython’s I/O layer. It is more invasive than Go’s approach, but more thorough than anything Node.js currently offers.
Java’s NIO.2 FileSystemProvider SPI, introduced in Java 7, lets you register alternative filesystem implementations that standard library code uses transparently. The in-memory Jimfs library implements this interface and is widely used in testing. Code that uses java.nio.file.Path and Files APIs works against Jimfs without modification.
The pattern across these ecosystems is the same: define a filesystem abstraction as an interface, implement it for the real OS and for testing, and let the standard library accept the interface rather than a concrete implementation. Node.js has never done this for fs.
What a VFS Hook Would Require
For a Node.js VFS to fix the testing problem, it would need to intercept at the binding layer, where JavaScript fs calls translate to libuv I/O requests. A JavaScript-layer module replacement cannot reach native addons. A hook at the C++ binding level, before libuv sees the call, would be visible to all callers regardless of whether they arrived through JavaScript or through native code.
The practical shape of the API would need to support path-scoped providers so that test volumes can coexist with real I/O for paths outside the test scope:
import { registerFSProvider } from 'node:fs';
const vol = new MemVolume({
'/test-fixtures/config.json': '{"port":3000}',
});
const handle = registerFSProvider(vol, { prefix: '/test-fixtures' });
// in teardown
handle.unregister();
Code that reads /test-fixtures/config.json hits the in-memory volume. Code that reads anything else hits the real filesystem. Native addons that go through the same binding layer see the same routing.
The obstacle is performance. Any hook in the readFile hot path adds overhead to every file read in every Node.js application, even when no virtual provider is registered. Module hooks avoided this concern because module loading is not latency-sensitive. File I/O is different. The Node.js TSC would need a design where the check is a fast branch on a null pointer or an absent flag, with no cost imposed on applications that never register a provider.
The plausible path is scoped: VFS support for SEA asset embedding first, where the scope is bounded and the overhead can be isolated to opted-in binaries, then a general provider API as an extension. Test frameworks would benefit from the same infrastructure that makes single-executable assets transparent, because the two use cases are the same missing abstraction appearing in two different contexts.
The current state distributes a maintenance cost across every Node.js test suite that touches the filesystem. Choosing between monkey-patched module caches, in-memory implementations with native-addon gaps, and real temporary directories is a tradeoff that does not exist in Go, Java, or Python. The abstraction that would eliminate it is not novel, and Node.js already has the precedent in its module hook system. File I/O is waiting for the same treatment that module loading received.