Skip to content

Commit 10b55a5

Browse files
mcollinaclaude
andcommitted
test_runner: add mock.fs() for file system mocking
Add mock.fs() API to the test runner's MockTracker class, enabling tests to create isolated virtual file systems without touching disk. Features: - t.mock.fs({ prefix, files }) creates a mock file system - Files accessible via standard fs APIs (readFileSync, existsSync, etc.) - Supports require() from virtual files - Supports dynamic file content via functions - Automatic cleanup when test completes - Multiple mock instances can coexist Also adds fs.existsSync hook to VFS module_hooks.js to properly intercept existence checks for virtual files. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3833d98 commit 10b55a5

File tree

4 files changed

+464
-0
lines changed

4 files changed

+464
-0
lines changed

doc/api/test.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2334,6 +2334,92 @@ test('mocks a counting function', (t) => {
23342334
});
23352335
```
23362336

2337+
### `mock.fs([options])`
2338+
2339+
<!-- YAML
2340+
added: REPLACEME
2341+
-->
2342+
2343+
> Stability: 1.0 - Early development
2344+
2345+
* `options` {Object} Optional configuration options for the mock file system.
2346+
The following properties are supported:
2347+
* `prefix` {string} The mount point prefix for the virtual file system.
2348+
**Default:** `'/mock'`.
2349+
* `files` {Object} An optional object where keys are file paths (relative to
2350+
the VFS root) and values are the file contents. Contents can be strings,
2351+
Buffers, or functions that return strings/Buffers.
2352+
* Returns: {MockFSContext} An object that can be used to manage the mock file
2353+
system.
2354+
2355+
This function creates a mock file system using the Virtual File System (VFS).
2356+
The mock file system is automatically cleaned up when the test completes.
2357+
2358+
The returned `MockFSContext` object provides the following methods and
2359+
properties:
2360+
2361+
* `vfs` {VirtualFileSystem} The underlying VFS instance.
2362+
* `prefix` {string} The mount prefix.
2363+
* `addFile(path, content)` Adds a file to the mock file system.
2364+
* `addDirectory(path[, populate])` Adds a directory to the mock file system.
2365+
* `existsSync(path)` Checks if a path exists (path is relative to prefix).
2366+
* `restore()` Manually restores the file system to its original state.
2367+
2368+
The following example demonstrates how to create a mock file system for testing:
2369+
2370+
```js
2371+
const { test } = require('node:test');
2372+
const assert = require('node:assert');
2373+
const fs = require('node:fs');
2374+
2375+
test('reads configuration from mock file', (t) => {
2376+
const mockFs = t.mock.fs({
2377+
prefix: '/app',
2378+
files: {
2379+
'/config.json': JSON.stringify({ debug: true }),
2380+
'/data/users.txt': 'user1\nuser2\nuser3',
2381+
},
2382+
});
2383+
2384+
// Files are accessible via standard fs APIs
2385+
const config = JSON.parse(fs.readFileSync('/app/config.json', 'utf8'));
2386+
assert.strictEqual(config.debug, true);
2387+
2388+
// Check file existence
2389+
assert.strictEqual(fs.existsSync('/app/config.json'), true);
2390+
assert.strictEqual(fs.existsSync('/app/missing.txt'), false);
2391+
2392+
// Use mockFs.existsSync for paths relative to prefix
2393+
assert.strictEqual(mockFs.existsSync('/config.json'), true);
2394+
});
2395+
2396+
test('supports dynamic file content', (t) => {
2397+
let counter = 0;
2398+
const mockFs = t.mock.fs({ prefix: '/dynamic' });
2399+
2400+
mockFs.addFile('/counter.txt', () => {
2401+
counter++;
2402+
return String(counter);
2403+
});
2404+
2405+
// Each read calls the function
2406+
assert.strictEqual(fs.readFileSync('/dynamic/counter.txt', 'utf8'), '1');
2407+
assert.strictEqual(fs.readFileSync('/dynamic/counter.txt', 'utf8'), '2');
2408+
});
2409+
2410+
test('supports require from mock files', (t) => {
2411+
t.mock.fs({
2412+
prefix: '/modules',
2413+
files: {
2414+
'/math.js': 'module.exports = { add: (a, b) => a + b };',
2415+
},
2416+
});
2417+
2418+
const math = require('/modules/math.js');
2419+
assert.strictEqual(math.add(2, 3), 5);
2420+
});
2421+
```
2422+
23372423
### `mock.getter(object, methodName[, implementation][, options])`
23382424

23392425
<!-- YAML

lib/internal/test_runner/mock/mock.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,73 @@ class MockPropertyContext {
402402

403403
const { restore: restoreProperty } = MockPropertyContext.prototype;
404404

405+
/**
406+
* Context for mocking the file system using VFS.
407+
*/
408+
class MockFSContext {
409+
#vfs;
410+
#prefix;
411+
412+
constructor(vfs, prefix) {
413+
this.#vfs = vfs;
414+
this.#prefix = prefix;
415+
}
416+
417+
/**
418+
* Gets the underlying VirtualFileSystem instance.
419+
* @returns {VirtualFileSystem}
420+
*/
421+
get vfs() {
422+
return this.#vfs;
423+
}
424+
425+
/**
426+
* Gets the mount prefix for the mock file system.
427+
* @returns {string}
428+
*/
429+
get prefix() {
430+
return this.#prefix;
431+
}
432+
433+
/**
434+
* Adds a file to the mock file system.
435+
* @param {string} path - The path of the file.
436+
* @param {string|Buffer|Function} content - The file content.
437+
*/
438+
addFile(path, content) {
439+
this.#vfs.addFile(path, content);
440+
}
441+
442+
/**
443+
* Adds a directory to the mock file system.
444+
* @param {string} path - The path of the directory.
445+
* @param {Function} [populate] - Optional callback to populate the directory.
446+
*/
447+
addDirectory(path, populate) {
448+
this.#vfs.addDirectory(path, populate);
449+
}
450+
451+
/**
452+
* Checks if a path exists in the mock file system.
453+
* @param {string} path - The path to check (relative to prefix).
454+
* @returns {boolean}
455+
*/
456+
existsSync(path) {
457+
// Prepend prefix to path for VFS lookup
458+
const fullPath = this.#prefix + (StringPrototypeStartsWith(path, '/') ? path : '/' + path);
459+
return this.#vfs.existsSync(fullPath);
460+
}
461+
462+
/**
463+
* Restores the file system to its original state.
464+
*/
465+
restore() {
466+
this.#vfs.unmount();
467+
}
468+
}
469+
470+
const { restore: restoreFS } = MockFSContext.prototype;
471+
405472
class MockTracker {
406473
#mocks = [];
407474
#timers;
@@ -725,6 +792,45 @@ class MockTracker {
725792
});
726793
}
727794

795+
/**
796+
* Creates a mock file system using VFS.
797+
* @param {object} [options] - Options for the mock file system.
798+
* @param {string} [options.prefix='/mock'] - The mount prefix for the VFS.
799+
* @param {object} [options.files] - Initial files to add (path: content pairs).
800+
* @returns {MockFSContext} The mock file system context.
801+
*/
802+
fs(options = kEmptyObject) {
803+
validateObject(options, 'options');
804+
const { prefix = '/mock', files } = options;
805+
if (files !== undefined) {
806+
validateObject(files, 'options.files');
807+
}
808+
809+
const { VirtualFileSystem } = require('internal/vfs/virtual_fs');
810+
const vfs = new VirtualFileSystem({ moduleHooks: true });
811+
812+
// Add initial files if provided
813+
if (files) {
814+
const paths = ObjectKeys(files);
815+
for (let i = 0; i < paths.length; i++) {
816+
const path = paths[i];
817+
vfs.addFile(path, files[path]);
818+
}
819+
}
820+
821+
// Mount the VFS at the specified prefix
822+
vfs.mount(prefix);
823+
824+
const ctx = new MockFSContext(vfs, prefix);
825+
ArrayPrototypePush(this.#mocks, {
826+
__proto__: null,
827+
ctx,
828+
restore: restoreFS,
829+
});
830+
831+
return ctx;
832+
}
833+
728834
/**
729835
* Resets the mock tracker, restoring all mocks and clearing timers.
730836
*/

lib/internal/vfs/module_hooks.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ let originalLstatSync = null;
2727
let originalStatSync = null;
2828
// Original fs.readdirSync function
2929
let originalReaddirSync = null;
30+
// Original fs.existsSync function
31+
let originalExistsSync = null;
3032
// Original fs/promises.readdir function
3133
let originalPromisesReaddir = null;
3234
// Original fs/promises.lstat function
@@ -123,6 +125,27 @@ function findVFSForRead(filename, options) {
123125
return null;
124126
}
125127

128+
/**
129+
* Checks all active VFS instances for existence.
130+
* @param {string} filename The absolute path to check
131+
* @returns {{ vfs: VirtualFileSystem, exists: boolean }|null}
132+
*/
133+
function findVFSForExists(filename) {
134+
const normalized = normalizePath(filename);
135+
for (let i = 0; i < activeVFSList.length; i++) {
136+
const vfs = activeVFSList[i];
137+
if (vfs.shouldHandle(normalized)) {
138+
// For mounted VFS, we handle the path (return exists result)
139+
// For overlay VFS, we only handle if it exists in VFS
140+
const exists = vfs.existsSync(normalized);
141+
if (vfs.isMounted || exists) {
142+
return { vfs, exists };
143+
}
144+
}
145+
}
146+
return null;
147+
}
148+
126149
/**
127150
* Checks all active VFS instances for realpath.
128151
* @param {string} filename The path to resolve
@@ -500,6 +523,19 @@ function installHooks() {
500523
return originalReaddirSync.call(fs, path, options);
501524
};
502525

526+
// Override fs.existsSync
527+
originalExistsSync = fs.existsSync;
528+
fs.existsSync = function vfsExistsSync(path) {
529+
if (typeof path === 'string' || path instanceof URL) {
530+
const pathStr = typeof path === 'string' ? path : path.pathname;
531+
const vfsResult = findVFSForExists(pathStr);
532+
if (vfsResult !== null) {
533+
return vfsResult.exists;
534+
}
535+
}
536+
return originalExistsSync.call(fs, path);
537+
};
538+
503539
// Hook fs/promises for async glob support
504540
const fsPromises = require('fs/promises');
505541

0 commit comments

Comments
 (0)