Skip to content

Commit 1462eb5

Browse files
committed
test_runner: add mock file system API
Add t.mock.fs(): an in-memory mock file system backed by a mounted virtual file system. The mock lives at a reserved mount point exposed as mountPoint, so it never shadows real files, and it is unmounted automatically when the test finishes. Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 7177c14 commit 1462eb5

4 files changed

Lines changed: 536 additions & 0 deletions

File tree

doc/api/test.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2331,6 +2331,89 @@ This function is used to customize the location of the snapshot file used for
23312331
snapshot testing. By default, the snapshot filename is the same as the entry
23322332
point filename with a `.snapshot` file extension.
23332333

2334+
## Class: `MockFSContext`
2335+
2336+
<!-- YAML
2337+
added: REPLACEME
2338+
-->
2339+
2340+
> Stability: 1.0 - Early development
2341+
2342+
The `MockFSContext` class is returned by [`mock.fs()`][] and is used to
2343+
inspect and extend a mock file system.
2344+
2345+
### `mockFs.addDirectory(path)`
2346+
2347+
<!-- YAML
2348+
added: REPLACEME
2349+
-->
2350+
2351+
* `path` {string} The path of the directory, relative to the mount point.
2352+
* Returns: {string} The absolute path of the created directory.
2353+
2354+
Adds a directory to the mock file system. Missing parent directories are
2355+
created automatically.
2356+
2357+
### `mockFs.addFile(path, content)`
2358+
2359+
<!-- YAML
2360+
added: REPLACEME
2361+
-->
2362+
2363+
* `path` {string} The path of the file, relative to the mount point.
2364+
* `content` {string|Buffer|TypedArray|DataView} The file content.
2365+
* Returns: {string} The absolute path of the created file.
2366+
2367+
Adds a file to the mock file system. Missing parent directories are
2368+
created automatically.
2369+
2370+
### `mockFs.existsSync(path)`
2371+
2372+
<!-- YAML
2373+
added: REPLACEME
2374+
-->
2375+
2376+
* `path` {string} The path to check, relative to the mount point.
2377+
* Returns: {boolean}
2378+
2379+
Returns `true` if the path exists in the mock file system, and `false`
2380+
otherwise, including once the mock has been restored.
2381+
2382+
### `mockFs.mountPoint`
2383+
2384+
<!-- YAML
2385+
added: REPLACEME
2386+
-->
2387+
2388+
* Type: {string|null}
2389+
2390+
The absolute path where the mock file system is mounted, or `null` once
2391+
the mock has been restored. The mount point is a reserved path assigned
2392+
by the [virtual file system][] when the mock is created; it never
2393+
shadows real files or directories. Join it with relative paths to access
2394+
the mock's files through the `node:fs` APIs.
2395+
2396+
### `mockFs.restore()`
2397+
2398+
<!-- YAML
2399+
added: REPLACEME
2400+
-->
2401+
2402+
Unmounts the mock file system. Once restored, the mock's files are no
2403+
longer accessible and files can no longer be added. Calling this
2404+
function more than once has no effect. This function is called
2405+
automatically when the associated test finishes.
2406+
2407+
### `mockFs.vfs`
2408+
2409+
<!-- YAML
2410+
added: REPLACEME
2411+
-->
2412+
2413+
* Type: {VirtualFileSystem}
2414+
2415+
The underlying [`VirtualFileSystem`][] instance.
2416+
23342417
## Class: `MockFunctionContext`
23352418

23362419
<!-- YAML
@@ -2658,6 +2741,64 @@ test('mocks a counting function', (t) => {
26582741
});
26592742
```
26602743

2744+
### `mock.fs([options])`
2745+
2746+
<!-- YAML
2747+
added: REPLACEME
2748+
-->
2749+
2750+
> Stability: 1.0 - Early development
2751+
2752+
* `options` {Object} Optional configuration options for the mock file
2753+
system. The following properties are supported:
2754+
* `files` {Object} Initial files to create. Keys are file paths relative
2755+
to the mount point, and values are the file contents as {string} or
2756+
{Buffer}. Missing parent directories are created automatically.
2757+
* Returns: {MockFSContext} An object that can be used to manage the mock
2758+
file system.
2759+
2760+
This function creates an in-memory mock file system backed by the
2761+
[virtual file system][]. The mock is mounted at a reserved mount point
2762+
that is assigned when the mock is created and exposed as
2763+
[`mockFs.mountPoint`][], so it never shadows real files or directories.
2764+
Paths obtained by joining the mount point with a relative path work with
2765+
the regular `node:fs` APIs and can be loaded with `require()` and
2766+
`import`.
2767+
2768+
If this function is invoked through a `TestContext`, the mock file
2769+
system is unmounted automatically when the test finishes.
2770+
2771+
```js
2772+
const { test } = require('node:test');
2773+
const assert = require('node:assert');
2774+
const fs = require('node:fs');
2775+
const path = require('node:path');
2776+
2777+
test('reads configuration from a mock file', (t) => {
2778+
const mockFs = t.mock.fs({
2779+
files: {
2780+
'config.json': JSON.stringify({ debug: true }),
2781+
'data/users.txt': 'user1\nuser2\nuser3',
2782+
},
2783+
});
2784+
2785+
// Files are accessible via standard fs APIs under the mount point.
2786+
const configPath = path.join(mockFs.mountPoint, 'config.json');
2787+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
2788+
assert.strictEqual(config.debug, true);
2789+
2790+
// Files can be added after creation. addFile() returns the
2791+
// absolute path of the new file.
2792+
const readmePath = mockFs.addFile('README.md', '# Hello');
2793+
assert.strictEqual(fs.readFileSync(readmePath, 'utf8'), '# Hello');
2794+
2795+
// Modules in the mock file system can be loaded with require()
2796+
// and import.
2797+
const modPath = mockFs.addFile('mod.js', 'module.exports = 42;');
2798+
assert.strictEqual(require(modPath), 42);
2799+
});
2800+
```
2801+
26612802
### `mock.getter(object, methodName[, implementation][, options])`
26622803

26632804
<!-- YAML
@@ -5035,6 +5176,7 @@ test.describe('my suite', (suite) => {
50355176
[`SuiteContext`]: #class-suitecontext
50365177
[`TestContext`]: #class-testcontext
50375178
[`TracingChannel`]: diagnostics_channel.md#class-tracingchannel
5179+
[`VirtualFileSystem`]: vfs.md#class-virtualfilesystem
50385180
[`assert.throws`]: assert.md#assertthrowsfn-error-message
50395181
[`context.diagnostic`]: #contextdiagnosticmessage
50405182
[`context.log`]: #contextlogmessage-data
@@ -5045,6 +5187,8 @@ test.describe('my suite', (suite) => {
50455187
[`diagnostics_channel`]: diagnostics_channel.md
50465188
[`glob(7)`]: https://man7.org/linux/man-pages/man7/glob.7.html
50475189
[`it()`]: #itname-options-fn
5190+
[`mock.fs()`]: #mockfsoptions
5191+
[`mockFs.mountPoint`]: #mockfsmountpoint
50485192
[`run()`]: #runoptions
50495193
[`suite()`]: #suitename-options-fn
50505194
[`test()`]: #testname-options-fn
@@ -5059,3 +5203,4 @@ test.describe('my suite', (suite) => {
50595203
[suite options]: #suitename-options-fn
50605204
[test reporters]: #test-reporters
50615205
[test runner execution model]: #test-runner-execution-model
5206+
[virtual file system]: vfs.md

doc/api/vfs.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,12 @@ The SEA configuration parser will error if either combination is detected.
450450
See the [Single Executable Application][] documentation for more information
451451
on creating SEA builds with assets.
452452

453+
## Use with the test runner
454+
455+
The [`mock.fs()`][] API of the `node:test` module creates a mock file
456+
system backed by a mounted `VirtualFileSystem`, which is unmounted
457+
automatically when the associated test finishes.
458+
453459
## Class: `VirtualProvider`
454460

455461
<!-- YAML
@@ -631,6 +637,7 @@ fields use synthetic but stable values:
631637
[`fs.BigIntStats`]: fs.md#class-fsstats
632638
[`fs.Stats`]: fs.md#class-fsstats
633639
[`import.meta.resolve()`]: esm.md#importmetaresolvespecifier
640+
[`mock.fs()`]: test.md#mockfsoptions
634641
[`node:fs`]: fs.md
635642
[`require()`]: modules.md#requireid
636643
[`require.resolve()`]: modules.md#requireresolverequest-options

lib/internal/test_runner/mock/mock.js

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,11 @@ const {
5454
validateInteger,
5555
validateObject,
5656
validateOneOf,
57+
validateString,
5758
} = require('internal/validators');
5859
const { MockTimers } = require('internal/test_runner/mock/mock_timers');
60+
const { isArrayBufferView } = require('internal/util/types');
61+
const { dirname, join } = require('path');
5962
const {
6063
Module,
6164
} = require('internal/modules/cjs/loader');
@@ -449,6 +452,103 @@ class MockPropertyContext {
449452

450453
const { restore: restoreProperty } = MockPropertyContext.prototype;
451454

455+
/**
456+
* Context for a mock file system backed by a mounted virtual file
457+
* system. Returned by MockTracker.fs().
458+
*/
459+
class MockFSContext {
460+
#vfs;
461+
462+
constructor(vfs) {
463+
this.#vfs = vfs;
464+
}
465+
466+
/**
467+
* The underlying VirtualFileSystem instance.
468+
* @type {VirtualFileSystem}
469+
*/
470+
get vfs() {
471+
return this.#vfs;
472+
}
473+
474+
/**
475+
* The mount point of the mock file system, or null once restored.
476+
* @type {string|null}
477+
*/
478+
get mountPoint() {
479+
return this.#vfs.mountPoint;
480+
}
481+
482+
#resolve(filePath) {
483+
validateString(filePath, 'path');
484+
const mountPoint = this.#vfs.mountPoint;
485+
if (mountPoint === null) {
486+
throw new ERR_INVALID_STATE('The mock file system has been restored');
487+
}
488+
return join(mountPoint, filePath);
489+
}
490+
491+
/**
492+
* Adds a file to the mock file system, creating parent directories
493+
* as needed.
494+
* @param {string} filePath - The path of the file, relative to the mount
495+
* point.
496+
* @param {string|Buffer} content - The file content.
497+
* @returns {string} The absolute path of the created file.
498+
*/
499+
addFile(filePath, content) {
500+
const fullPath = this.#resolve(filePath);
501+
if (typeof content !== 'string' && !isArrayBufferView(content)) {
502+
throw new ERR_INVALID_ARG_TYPE(
503+
'content', ['string', 'Buffer', 'TypedArray', 'DataView'], content,
504+
);
505+
}
506+
const parentDir = dirname(fullPath);
507+
if (parentDir !== this.#vfs.mountPoint) {
508+
this.#vfs.mkdirSync(parentDir, { __proto__: null, recursive: true });
509+
}
510+
this.#vfs.writeFileSync(fullPath, content);
511+
return fullPath;
512+
}
513+
514+
/**
515+
* Adds a directory to the mock file system, creating parent
516+
* directories as needed.
517+
* @param {string} dirPath - The path of the directory, relative to the
518+
* mount point.
519+
* @returns {string} The absolute path of the created directory.
520+
*/
521+
addDirectory(dirPath) {
522+
const fullPath = this.#resolve(dirPath);
523+
this.#vfs.mkdirSync(fullPath, { __proto__: null, recursive: true });
524+
return fullPath;
525+
}
526+
527+
/**
528+
* Checks if a path exists in the mock file system.
529+
* @param {string} filePath - The path to check, relative to the mount
530+
* point.
531+
* @returns {boolean}
532+
*/
533+
existsSync(filePath) {
534+
if (this.#vfs.mountPoint === null) {
535+
return false;
536+
}
537+
return this.#vfs.existsSync(this.#resolve(filePath));
538+
}
539+
540+
/**
541+
* Unmounts the mock file system.
542+
*/
543+
restore() {
544+
if (this.#vfs.mounted) {
545+
this.#vfs.unmount();
546+
}
547+
}
548+
}
549+
550+
const { restore: restoreFileSystem } = MockFSContext.prototype;
551+
452552
class MockTracker {
453553
#mocks = [];
454554
#timers;
@@ -493,6 +593,45 @@ class MockTracker {
493593
return this.#setupMock(ctx, original);
494594
}
495595

596+
/**
597+
* Creates a mock file system backed by a mounted virtual file system.
598+
* @param {object} [options] - Options for the mock file system.
599+
* @param {object} [options.files] - Initial files to create. Keys are file
600+
* paths relative to the mount point and values are the file contents.
601+
* @returns {MockFSContext} The mock file system context.
602+
*/
603+
fs(options = kEmptyObject) {
604+
emitExperimentalWarning('The mock.fs API');
605+
validateObject(options, 'options');
606+
const { files } = options;
607+
if (files !== undefined) {
608+
validateObject(files, 'options.files');
609+
}
610+
611+
const { VirtualFileSystem } = require('internal/vfs/file_system');
612+
const vfs = new VirtualFileSystem({
613+
__proto__: null,
614+
emitExperimentalWarning: false,
615+
});
616+
vfs.mount();
617+
const ctx = new MockFSContext(vfs);
618+
619+
if (files !== undefined) {
620+
const paths = ObjectKeys(files);
621+
for (let i = 0; i < paths.length; i++) {
622+
ctx.addFile(paths[i], files[paths[i]]);
623+
}
624+
}
625+
626+
ArrayPrototypePush(this.#mocks, {
627+
__proto__: null,
628+
ctx,
629+
restore: restoreFileSystem,
630+
});
631+
632+
return ctx;
633+
}
634+
496635
/**
497636
* Creates a method tracker for a specified object or function.
498637
* @param {(object | Function)} objectOrFunction - The object or function containing the method to be tracked.

0 commit comments

Comments
 (0)