Skip to content

Conversation

@sozua
Copy link

@sozua sozua commented Jan 22, 2026

Add experimental mock.fs API to the test runner for mocking file system operations in tests. This allows tests to simulate file operations without actually reading from or writing to disk.

Note: There is an existing draft PR #59194 by @joaoGabriel55 working on this feature, though it appears to have stalled (last updated August 2025). This implementation takes a different approach based on maintainer feedback in the issue discussion:

  • Uses mock.fs naming as suggested by @MoLow
  • Gated behind --experimental-test-fs-mocks flag as suggested by @ljharb
  • Includes callback and promise variants, not just sync methods

Features

  • Virtual file system with string, Buffer, or Uint8Array content
  • Supports sync, callback, and promise variants of fs APIs
  • Mocked APIs: readFile, writeFile, appendFile, stat, lstat, access, exists, unlink, mkdir, rmdir, readdir
  • Isolation mode (isolate: true) to completely block real filesystem access
  • Selective API mocking via the apis option
  • Write operations always go to virtual fs (prevents accidental disk writes)
  • Symbol.dispose support for automatic cleanup

Examples

Basic usage

import { test } from 'node:test';
import fs from 'node:fs';
import assert from 'node:assert';

test('mock file system', (t) => {
  t.mock.fs.enable({
    files: {
      '/virtual/config.json': '{"key": "value"}',
    },
  });

  const config = JSON.parse(fs.readFileSync('/virtual/config.json', 'utf8'));
  assert.strictEqual(config.key, 'value');
});

Isolation mode

test('complete isolation from real filesystem', (t) => {
  t.mock.fs.enable({
    files: {
      '/virtual/only.txt': 'isolated content',
    },
    isolate: true,
  });

  // Virtual file works
  assert.strictEqual(fs.readFileSync('/virtual/only.txt', 'utf8'), 'isolated content');

  // Real files are blocked
  assert.throws(() => fs.readFileSync('/etc/passwd'), { code: 'ENOENT' });
});

Write operations

test('writes always go to virtual fs', (t) => {
  t.mock.fs.enable({ files: {} });

  // Write to virtual fs
  fs.writeFileSync('/tmp/test.txt', 'safe write');

  // Read it back
  assert.strictEqual(fs.readFileSync('/tmp/test.txt', 'utf8'), 'safe write');

  // After test ends, nothing written to real /tmp
});

Async/promises

import fsPromises from 'node:fs/promises';

test('works with promises', async (t) => {
  t.mock.fs.enable({
    files: { '/data/file.txt': 'async content' },
  });

  const content = await fsPromises.readFile('/data/file.txt', 'utf8');
  assert.strictEqual(content, 'async content');
});

Directory operations

test('directory operations', (t) => {
  t.mock.fs.enable({
    files: {
      '/app/src/index.js': 'console.log("hello")',
      '/app/src/utils.js': 'export default {}',
    },
  });

  const entries = fs.readdirSync('/app/src');
  assert.deepStrictEqual(entries.sort(), ['index.js', 'utils.js']);
});

Next Steps

This PR provides an initial set of commonly-used fs operations. Future iterations could add file operations like rename, copyFile, rm, and truncate, as well as symlink support (symlink, readlink, link).

Refs: #55902

@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/config
  • @nodejs/test_runner

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Jan 22, 2026
* @enum {('readFile'|'writeFile'|'appendFile'|'stat'|'lstat'|'access'|
* 'exists'|'unlink'|'mkdir'|'rmdir'|'readdir')[]} Supported fs APIs
*/
const SUPPORTED_APIS = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are the unsupported ones? is the plan to support them gradually?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename, copyFile, rm, symlinks, etc. There is a lot of other operations from fs that arent included in here. And yes, I plan to support all of them gradually, although I think the operations in this iteration looks fine for the initial scope

@codecov
Copy link

codecov bot commented Jan 22, 2026

Codecov Report

❌ Patch coverage is 86.75388% with 162 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.83%. Comparing base (95852d7) to head (c12a9b7).
⚠️ Report is 133 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/test_runner/mock/mock_file_system.js 86.52% 160 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #61468      +/-   ##
==========================================
+ Coverage   88.54%   89.83%   +1.28%     
==========================================
  Files         704      672      -32     
  Lines      208753   204401    -4352     
  Branches    40280    39272    -1008     
==========================================
- Hits       184847   183621    -1226     
+ Misses      15907    13125    -2782     
+ Partials     7999     7655     -344     
Files with missing lines Coverage Δ
lib/internal/test_runner/mock/mock.js 98.75% <100.00%> (+0.02%) ⬆️
src/node_options.cc 77.92% <100.00%> (+0.12%) ⬆️
src/node_options.h 97.89% <100.00%> (ø)
lib/internal/test_runner/mock/mock_file_system.js 86.52% <86.52%> (ø)

... and 136 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pmarchini
Copy link
Member

Thanks for the contribution! I will take a look ASAP, though I think it would be great to have a review from @nodejs/fs as well.

I'm also wondering if it might make sense to break down this PR into even more granular iterations, as it's already a non-trivial change!

@nodejs/test_runner any thoughts?

@sozua
Copy link
Author

sozua commented Jan 22, 2026

Thanks @ljharb for the comment, really appreciate it! There was a lot of interesting stuff I learned while reviewing your questions. Some of them should already be resolved, while I'll resolve/comment on the others ASAP.

@pmarchini thanks and I think that makes total sense. In issue #55902, we already exchanged some ideas about this. I think it makes sense to centralize that discussion there, what do you think? Either way, I'm happy to close this PR and work it based on the chosen scope and granularity, if necessary!

@avivkeller avivkeller added semver-minor PRs that contain new features and should be released in the next minor version. notable-change PRs with changes that should be highlighted in changelogs. test_runner Issues and PRs related to the test runner subsystem. labels Jan 22, 2026
@github-actions
Copy link
Contributor

The notable-change PRs with changes that should be highlighted in changelogs. label has been added by @avivkeller.

Please suggest a text for the release notes if you'd like to include a more detailed summary, then proceed to update the PR description with the text or a link to the notable change suggested text comment. Otherwise, the commit will be placed in the Other Notable Changes section.

Comment on lines +3281 to +3283

Enables file system mocking.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should go after the typed list

Comment on lines +3303 to +3304
**Note:** When file system mocking is enabled, the mock automatically
creates parent directories for all virtual files.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
**Note:** When file system mocking is enabled, the mock automatically
creates parent directories for all virtual files.
When file system mocking is enabled, the mock automatically
creates parent directories for all virtual files.

added: REPLACEME
-->

> Stability: 1.0 - Early development
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
> Stability: 1.0 - Early development
> Stability: 1.0 - Early development. Enable this API via ...

Comment on lines +3417 to +3430
| Property | Type | Default |
| ------------------------------------------- | -------- | -------------------------------- |
| `dev` | `number` | `0` |
| `ino` | `number` | `0` |
| `mode` | `number` | File: `0o100644`, Dir: `0o40644` |
| `nlink` | `number` | `1` |
| `uid` | `number` | `0` |
| `gid` | `number` | `0` |
| `rdev` | `number` | `0` |
| `size` | `number` | Content length |
| `blksize` | `number` | `4096` |
| `blocks` | `number` | `ceil(size / 512)` |
| `atime`/`mtime`/`ctime`/`birthtime` | `Date` | Creation time |
| `atimeMs`/`mtimeMs`/`ctimeMs`/`birthtimeMs` | `number` | Creation time (ms) |
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder, and perhaps this is too much for a single PR, if these should be mock-able.

{
  files: {
    myFilePath: 'content',
    myOtherFile: { atime: 123, content: 'some data' },
  }
}

Comment on lines +52 to +56
const { call: FunctionCall, bind: FunctionBind } = Function.prototype;
const BufferPrototypeToString = FunctionCall.call(FunctionBind, FunctionCall, Buffer.prototype.toString);
const BufferFrom = Buffer.from;
const BufferConcat = Buffer.concat;
const BufferAlloc = Buffer.alloc;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we have these in primordials?

Comment on lines +62 to +66
const {
resolve: pathResolve,
dirname: pathDirname,
sep: pathSep,
} = require('path');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const {
resolve: pathResolve,
dirname: pathDirname,
sep: pathSep,
} = require('path');
const {
resolve,
dirname,
sep,
} = require('path');

OR

Suggested change
const {
resolve: pathResolve,
dirname: pathDirname,
sep: pathSep,
} = require('path');
const path = require('path');

These renames seem odd to me.

Comment on lines +120 to +138
__proto__: Stats.prototype,
dev: 0,
ino: 0,
mode: isDirectory ? S_IFDIR | mode : S_IFREG | mode,
nlink: 1,
uid: 0,
gid: 0,
rdev: 0,
size,
blksize: DEFAULT_BLKSIZE,
blocks: MathCeil(size / BLOCK_SIZE),
atimeMs: nowMs,
mtimeMs: nowMs,
ctimeMs: nowMs,
birthtimeMs: nowMs,
atime: nowDate,
mtime: nowDate,
ctime: nowDate,
birthtime: nowDate,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we (for some reason) change the structure of this object in fs, we'd need to update it here as well. Can we ensure the validity of this object with a test or get its structure from fs?

Comment on lines +142 to +215
/**
* @param {string} syscall
* @param {string} filepath
* @returns {Error}
*/
function createENOENT(syscall, filepath) {
return new UVException({
__proto__: null,
errno: UV_ENOENT,
syscall,
path: filepath,
message: 'no such file or directory',
});
}

/**
* @param {string} syscall
* @param {string} filepath
* @returns {Error}
*/
function createENOTDIR(syscall, filepath) {
return new UVException({
__proto__: null,
errno: UV_ENOTDIR,
syscall,
path: filepath,
message: 'not a directory',
});
}

/**
* @param {string} syscall
* @param {string} filepath
* @returns {Error}
*/
function createENOTEMPTY(syscall, filepath) {
return new UVException({
__proto__: null,
errno: UV_ENOTEMPTY,
syscall,
path: filepath,
message: 'directory not empty',
});
}

/**
* @param {string} syscall
* @param {string} filepath
* @returns {Error}
*/
function createEEXIST(syscall, filepath) {
return new UVException({
__proto__: null,
errno: UV_EEXIST,
syscall,
path: filepath,
message: 'file already exists',
});
}

/**
* @param {string} syscall
* @param {string} filepath
* @returns {Error}
*/
function createEISDIR(syscall, filepath) {
return new UVException({
__proto__: null,
errno: UV_EISDIR,
syscall,
path: filepath,
message: 'illegal operation on a directory',
});
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few notes on these functions:

  1. Perhaps I missed some usages, but these functions are only called once, do they need to be functions?

  2. We need to ensure that these error messages line up with fs's. Can we add a test to validate that?

Comment on lines +217 to +227
/**
* @param {string} base
* @param {string} name
* @returns {string}
*/
function safePathJoin(base, name) {
if (StringPrototypeEndsWith(base, pathSep)) {
return base + name;
}
return base + pathSep + name;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's wrong with path.join?

Comment on lines +295 to +305
// Check for dangerous path segments that could lead to prototype pollution.
const segments = StringPrototypeSplit(filepath, pathSep);
for (let j = 0; j < segments.length; j++) {
if (kDangerousPathSegments.has(segments[j])) {
throw new ERR_INVALID_ARG_VALUE(
'options.files',
filepath,
'cannot contain __proto__, constructor, or prototype in path',
);
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iirc, Node.js trusts data passed to it's functions, thus we should trust the data passed here, and not check it for pollution, right?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. notable-change PRs with changes that should be highlighted in changelogs. semver-minor PRs that contain new features and should be released in the next minor version. test_runner Issues and PRs related to the test runner subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants