From a6fa773124e7fde861130522c3b8d2cd988d737f Mon Sep 17 00:00:00 2001 From: Sahinur Date: Mon, 12 Oct 2026 02:50:05 +0600 Subject: [PATCH] test_runner: support mocking non-existent modules Add a `virtual` option to `mock.module()`. When it is `true` and the specifier cannot be resolved, the mock is registered under a stable URL instead of throwing `ERR_MODULE_NOT_FOUND`: - bare specifiers map to a synthetic `node-test-mock:` URL; - relative, absolute and file: URL specifiers map to the file: URL they would resolve to, so different paths to the same missing file share one mock. The module loader's resolve hook and the CommonJS `Module._load` patch only fall back to a virtual mock when normal resolution fails with a "module not found" error, so existing modules are never shadowed. If the module does exist, `virtual: true` has no effect and the module is mocked as usual, so every path that resolves to it sees the mock. Fixes: https://github.com/nodejs/node/issues/55891 Refs: https://github.com/nodejs/node/pull/62298 Co-authored-by: Edy Silva Signed-off-by: Sahinur --- doc/api/test.md | 6 + lib/internal/test_runner/mock/loader.js | 56 +++++++- lib/internal/test_runner/mock/mock.js | 74 +++++++++- .../test-runner-module-mocking-virtual.js | 129 ++++++++++++++++++ 4 files changed, 257 insertions(+), 8 deletions(-) create mode 100644 test/parallel/test-runner-module-mocking-virtual.js diff --git a/doc/api/test.md b/doc/api/test.md index c48648baf170..24485a9adf58 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -2775,6 +2775,12 @@ changes: **This option cannot be used with `options.exports`.** This option is deprecated and will be removed in a later version. Prefer `options.exports`. + * `virtual` {boolean} If `true`, `specifier` is allowed to refer to a module + that cannot be resolved, such as a package that is not installed or a file + that does not exist. The mock is then created as an ECMAScript module that + can be loaded with both `import` and `require()`. If the module can be + resolved, this option has no effect and the module is mocked as usual. + **Default:** `false`. * Returns: {MockModuleContext} An object that can be used to manipulate the mock. This function is used to mock the exports of ECMAScript modules, CommonJS modules, JSON modules, and diff --git a/lib/internal/test_runner/mock/loader.js b/lib/internal/test_runner/mock/loader.js index d64ffdfa0965..99eb94bcbbab 100644 --- a/lib/internal/test_runner/mock/loader.js +++ b/lib/internal/test_runner/mock/loader.js @@ -2,9 +2,12 @@ const { JSONStringify, SafeMap, + StringPrototypeStartsWith, + encodeURIComponent, } = primordials; const kMockSearchParam = 'node-test-mock'; +const kVirtualMockProtocol = 'node-test-mock:'; const kBadExportsMessage = 'Cannot create mock because named exports ' + 'cannot be applied to the provided default export.'; @@ -15,10 +18,46 @@ let debug = require('internal/util/debuglog').debuglog('test_runner', (fn) => { const mocks = new SafeMap(); +/** + * Computes the URL used to identify a virtual mock, i.e. a mock created with + * `virtual: true` for a module that cannot be resolved. Relative and absolute + * specifiers map to the file: URL they would resolve to, so that the same file + * imported through different relative paths maps to the same mock. Bare + * specifiers map to a synthetic URL, as there is no file to point to. + * @param {string} specifier + * @param {string} [parentURL] + * @returns {string} + */ +function getVirtualMockURL(specifier, parentURL) { + if (StringPrototypeStartsWith(specifier, 'file:') || + StringPrototypeStartsWith(specifier, './') || + StringPrototypeStartsWith(specifier, '../') || + StringPrototypeStartsWith(specifier, '/')) { + return new URL(specifier, parentURL).href; + } + + return `${kVirtualMockProtocol}///${encodeURIComponent(specifier)}`; +} + function resolve(specifier, context, nextResolve) { debug('resolve hook entry, specifier = "%s", context = %o', specifier, context); - const nextResolveResult = nextResolve(specifier, context); + let nextResolveResult; + try { + nextResolveResult = nextResolve(specifier, context); + } catch (err) { + // The module does not exist. If a virtual mock was created for it, resolve + // to the mock instead of failing. + if (err?.code !== 'ERR_MODULE_NOT_FOUND') { + throw err; + } + const virtualURL = getVirtualMockURL(specifier, context.parentURL); + const virtualMock = mocks.get(virtualURL); + if (virtualMock?.active !== true || virtualMock.virtual !== true) { + throw err; + } + nextResolveResult = { __proto__: null, url: virtualURL, format: 'module' }; + } const mockSpecifier = nextResolveResult.url; const mock = mocks.get(mockSpecifier); @@ -51,9 +90,21 @@ function load(url, context, nextLoad) { const baseURL = parsedURL ? parsedURL.href : url; const mock = mocks.get(baseURL); + debug('load hook, mock = %o', mock); + + if (mock?.active === true && mock.virtual === true) { + // There is no module to load for a virtual mock, so do not call nextLoad(). + const result = { + __proto__: null, + format: 'module', + shortCircuit: true, + source: createSourceFromMock(mock, 'module'), + }; + debug('load hook finished (virtual mock), result = %o', result); + return result; + } const original = nextLoad(url, context); - debug('load hook, mock = %o', mock); if (mock?.active !== true) { return original; } @@ -144,6 +195,7 @@ if (module.exports === null || typeof module.exports !== 'object') { } module.exports = { + getVirtualMockURL, hooks: { __proto__: null, load, resolve }, mocks, constants: { diff --git a/lib/internal/test_runner/mock/mock.js b/lib/internal/test_runner/mock/mock.js index 996c8a7bcca0..452cb8b12d33 100644 --- a/lib/internal/test_runner/mock/mock.js +++ b/lib/internal/test_runner/mock/mock.js @@ -19,6 +19,7 @@ const { ReflectApply, ReflectConstruct, ReflectGet, + RegExpPrototypeExec, SafeMap, StringPrototypeIncludes, StringPrototypeSlice, @@ -65,6 +66,8 @@ const { _resolveFilename, isBuiltin, } = Module; +const { dirname, isAbsolute, resolve: resolvePath } = require('path'); +const kRelativePathRegExp = /^\.\.?(?:[\\/]|$)/; function kDefaultFunction() {} const enableModuleMocking = getOptionValue('--experimental-test-module-mocks'); const kSupportedFormats = [ @@ -85,6 +88,7 @@ const deprecateDefaultExport = deprecateProperty( 'mock.module(): options.defaultExport is deprecated. Use options.exports.default instead.', ); const { + getVirtualMockURL, hooks: mockHooks, mocks, constants: { kBadExportsMessage, kMockSearchParam }, @@ -214,12 +218,14 @@ class MockModuleContext { moduleExports, sharedState, specifier, + virtual, }) { const config = { __proto__: null, cache, moduleExports, caller, + virtual, }; sharedState.mockMap.set(baseURL, config); @@ -268,6 +274,7 @@ class MockModuleContext { format, localVersion, active: true, + virtual, }); } @@ -676,6 +683,7 @@ class MockTracker { const { cache, moduleExports, + virtual, } = normalizeModuleMockOptions(options); const sharedState = setupSharedModuleState(); @@ -689,8 +697,25 @@ class MockTracker { const hasFileProtocol = StringPrototypeStartsWith(filename, 'file://'); const caller = hasFileProtocol ? filename : pathToFileURL(filename).href; const request = { __proto__: null, specifier: mockSpecifier, attributes: kEmptyObject }; - const { format, url } = sharedState.moduleLoader.resolveSync(caller, request); - debug('module mock, url = "%s", format = "%s", caller = "%s"', url, format, caller); + let format; + let url; + let isVirtual = false; + try { + ({ format, url } = sharedState.moduleLoader.resolveSync(caller, request)); + } catch (err) { + // With `virtual: true`, a module that does not exist can be mocked. If + // the module does exist, it is mocked like any other module so that every + // path that resolves to it receives the mock. + if (!virtual || err?.code !== 'ERR_MODULE_NOT_FOUND' || + StringPrototypeStartsWith(specifier, 'node:')) { + throw err; + } + url = getVirtualMockURL(mockSpecifier, caller); + format = 'module'; + isVirtual = true; + } + debug('module mock, url = "%s", format = "%s", caller = "%s", virtual = %s', + url, format, caller, isVirtual); if (format) { // Format is not yet known for ambiguous files when detection is enabled. validateOneOf(format, 'format', kSupportedFormats); } @@ -708,15 +733,20 @@ class MockTracker { ); } - const fullPath = StringPrototypeStartsWith(url, 'file://') ? + let fullPath = StringPrototypeStartsWith(url, 'file://') ? fileURLToPath(url) : null; + if (isVirtual && fullPath === null) { + // Bare specifier virtual mocks have no file path. Use the synthetic URL + // as their CommonJS cache key. + fullPath = url; + } // For dual packages, the ESM resolver may return a different file than // CJS require() would for the same specifier (e.g., when a package's // "exports" field points to different files for the "import" and // "require" conditions). Compute the CJS-resolved path so that // require() of a mocked module also picks up the mock. // See https://github.com/nodejs/node/issues/58231. - const cjsPath = resolveAsCJS(mockSpecifier, caller, fullPath); + const cjsPath = isVirtual ? null : resolveAsCJS(mockSpecifier, caller, fullPath); const ctx = new MockModuleContext({ __proto__: null, baseURL: baseURL.href, @@ -728,6 +758,7 @@ class MockTracker { moduleExports, sharedState, specifier: mockSpecifier, + virtual: isVirtual, }); ArrayPrototypePush(this.#mocks, { @@ -865,8 +896,9 @@ class MockTracker { } function normalizeModuleMockOptions(options) { - const { cache = false } = options; + const { cache = false, virtual = false } = options; validateBoolean(cache, 'options.cache'); + validateBoolean(virtual, 'options.virtual'); const hasExports = 'exports' in options; const hasNamedExports = 'namedExports' in options; @@ -917,6 +949,7 @@ function normalizeModuleMockOptions(options) { __proto__: null, cache, moduleExports, + virtual, }; } @@ -975,7 +1008,11 @@ function cjsMockModuleLoad(request, parent, isMain) { if (isBuiltin(request)) { resolved = ensureNodeScheme(request); } else { - resolved = _resolveFilename(request, parent, isMain); + try { + resolved = _resolveFilename(request, parent, isMain); + } catch (err) { + resolved = resolveVirtualMock(this.mockMap, request, parent, err); + } } const config = this.mockMap.get(resolved); @@ -1025,6 +1062,31 @@ function cjsMockModuleLoad(request, parent, isMain) { return modExports; } +// Called when CommonJS resolution of `request` failed. If a virtual mock exists +// for `request`, return its CommonJS cache key. Otherwise, rethrow `err`. +function resolveVirtualMock(mockMap, request, parent, err) { + if (err?.code !== 'MODULE_NOT_FOUND' && err?.code !== 'ERR_MODULE_NOT_FOUND') { + throw err; + } + + const parentDir = parent?.filename ? dirname(parent.filename) : process.cwd(); + let key; + if (isAbsolute(request) || + RegExpPrototypeExec(kRelativePathRegExp, request) !== null) { + // Use path semantics so that Windows paths are handled as well. + key = resolvePath(parentDir, request); + } else { + const url = getVirtualMockURL(request, pathToFileURL(`${parentDir}/`).href); + key = StringPrototypeStartsWith(url, 'file://') ? fileURLToPath(url) : url; + } + + if (mockMap.get(key)?.virtual !== true) { + throw err; + } + + return key; +} + // Resolve `specifier` using CJS resolution rules so that mocks for dual // packages (e.g., a package whose "exports" field points to different files // for the "import" and "require" conditions) also intercept require(). diff --git a/test/parallel/test-runner-module-mocking-virtual.js b/test/parallel/test-runner-module-mocking-virtual.js new file mode 100644 index 000000000000..c548e7f654bc --- /dev/null +++ b/test/parallel/test-runner-module-mocking-virtual.js @@ -0,0 +1,129 @@ +// Flags: --experimental-test-module-mocks +'use strict'; + +// Tests for mocking modules that do not exist with `virtual: true`. +// Refs: https://github.com/nodejs/node/issues/55891 + +const common = require('../common'); +const { isMainThread } = require('worker_threads'); + +if (!isMainThread) { + common.skip('registering customization hooks in Workers does not work'); +} + +const fixtures = require('../common/fixtures'); +const assert = require('node:assert'); +const path = require('node:path'); +const { test } = require('node:test'); +const { pathToFileURL } = require('node:url'); + +test('input validation', (t) => { + assert.throws(() => { + t.mock.module('nonexistent-virtual-pkg', { virtual: 'yes' }); + }, { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +test('mocking a module that does not exist throws without virtual', (t) => { + assert.throws(() => { + t.mock.module('nonexistent-virtual-pkg', { exports: { foo: 1 } }); + }, { code: 'ERR_MODULE_NOT_FOUND' }); +}); + +test('virtual builtin modules are not supported', (t) => { + assert.throws(() => { + t.mock.module('node:nonexistent-virtual-builtin', { + virtual: true, + exports: { foo: 1 }, + }); + }, { code: 'ERR_MODULE_NOT_FOUND' }); +}); + +test('bare specifiers can be mocked in both module systems', async (t) => { + const mock = t.mock.module('nonexistent-virtual-pkg', { + virtual: true, + exports: { default: { val: 'default' }, fn: () => 'named' }, + }); + + const esmImpl = await import('nonexistent-virtual-pkg'); + // As for other ES module mocks, named exports are not applied to the + // default export. + assert.deepStrictEqual(esmImpl.default, { val: 'default' }); + assert.strictEqual(esmImpl.fn(), 'named'); + + const cjsImpl = require('nonexistent-virtual-pkg'); + assert.strictEqual(cjsImpl.val, 'default'); + assert.strictEqual(cjsImpl.fn(), 'named'); + + mock.restore(); + + await assert.rejects(import('nonexistent-virtual-pkg'), { + code: 'ERR_MODULE_NOT_FOUND', + }); + assert.throws(() => require('nonexistent-virtual-pkg'), { + code: 'MODULE_NOT_FOUND', + }); +}); + +test('relative paths can be mocked in both module systems', async (t) => { + const mock = t.mock.module('./nonexistent-virtual-file.js', { + virtual: true, + exports: { fn: () => 42 }, + }); + const absolutePath = path.join(__dirname, 'nonexistent-virtual-file.js'); + + assert.strictEqual(require('./nonexistent-virtual-file.js').fn(), 42); + assert.strictEqual(require(absolutePath).fn(), 42); + assert.strictEqual((await import('./nonexistent-virtual-file.js')).fn(), 42); + assert.strictEqual((await import(pathToFileURL(absolutePath))).fn(), 42); + + mock.restore(); + + assert.throws(() => require('./nonexistent-virtual-file.js'), { + code: 'MODULE_NOT_FOUND', + }); + await assert.rejects(import('./nonexistent-virtual-file.js'), { + code: 'ERR_MODULE_NOT_FOUND', + }); +}); + +test('existing modules are mocked like non-virtual mocks', async (t) => { + const fixture = fixtures.path('module-mocking', 'basic-cjs.js'); + const original = require(fixture); + assert.strictEqual(original.string, 'original cjs string'); + + // The mock must be visible no matter which specifier resolves to the module. + t.mock.module(pathToFileURL(fixture), { + virtual: true, + exports: { string: 'mocked' }, + }); + + assert.strictEqual(require(fixture).string, 'mocked'); + const relativePath = `./${path.relative(__dirname, fixture)}`; + assert.strictEqual(require(relativePath).string, 'mocked'); + assert.strictEqual((await import(pathToFileURL(fixture))).string, 'mocked'); +}); + +test('cache option works with virtual mocks', async (t) => { + const mock = t.mock.module('nonexistent-virtual-cached', { + virtual: true, + cache: true, + exports: { default: { n: 1 } }, + }); + + const first = require('nonexistent-virtual-cached'); + assert.strictEqual(first.n, 1); + assert.strictEqual(require('nonexistent-virtual-cached'), first); + const esm1 = await import('nonexistent-virtual-cached'); + const esm2 = await import('nonexistent-virtual-cached'); + assert.strictEqual(esm1, esm2); + + mock.restore(); + + // A new mock of the same specifier must not see the previous cached value. + t.mock.module('nonexistent-virtual-cached', { + virtual: true, + cache: true, + exports: { default: { n: 2 } }, + }); + assert.strictEqual(require('nonexistent-virtual-cached').n, 2); +});