From 0498de3e9533ae68482a0e14fea58f463bc685cf Mon Sep 17 00:00:00 2001 From: sangwook Date: Sun, 8 Feb 2026 10:11:45 +0900 Subject: [PATCH 1/4] test_runner: add exports option for module mocks Add options.exports support in mock.module() and normalize option shapes through a shared exports path. Keep defaultExport and namedExports as aliases, emit runtime deprecation warnings for legacy options, and update docs and tests, including output fixtures and coverage snapshots. Refs: https://github.com/nodejs/node/issues/58443 --- doc/api/test.md | 15 ++- lib/internal/test_runner/mock/mock.js | 97 +++++++++++++-- .../test-runner/output/coverage-with-mock.mjs | 2 +- .../output/typescript-coverage.mts | 6 +- .../output/typescript-coverage.snapshot | 2 +- test/parallel/test-runner-module-mocking.js | 111 ++++++++++++++---- 6 files changed, 199 insertions(+), 34 deletions(-) diff --git a/doc/api/test.md b/doc/api/test.md index 40fb08d0d5b181..223ccf53267078 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -2431,16 +2431,29 @@ changes: generates a new mock module. If `true`, subsequent calls will return the same module mock, and the mock module is inserted into the CommonJS cache. **Default:** false. + * `exports` {Object} Optional mocked exports. The `default` property, if + provided, is used as the mocked module's default export. All other own + enumerable properties are used as named exports. + * If the mock is a CommonJS or builtin module, `exports.default` is used as + the value of `module.exports`. + * If `exports.default` is not provided for a CommonJS or builtin mock, + `module.exports` defaults to an empty object. + * If named exports are provided with a non-object default export, the mock + throws an exception when used as a CommonJS or builtin module. * `defaultExport` {any} An optional value used as the mocked module's default export. If this value is not provided, ESM mocks do not include a default export. If the mock is a CommonJS or builtin module, this setting is used as the value of `module.exports`. If this value is not provided, CJS and builtin mocks use an empty object as the value of `module.exports`. + This option is deprecated and will be removed in a future major release. + Prefer `options.exports.default`. * `namedExports` {Object} An optional object whose keys and values are used to create the named exports of the mock module. If the mock is a CommonJS or builtin module, these values are copied onto `module.exports`. Therefore, if a mock is created with both named exports and a non-object default export, the mock will throw an exception when used as a CJS or builtin module. + This option is deprecated and will be removed in a future major release. + Prefer `options.exports`. * 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 @@ -2455,7 +2468,7 @@ test('mocks a builtin module in both module systems', async (t) => { // Create a mock of 'node:readline' with a named export named 'fn', which // does not exist in the original 'node:readline' module. const mock = t.mock.module('node:readline', { - namedExports: { fn() { return 42; } }, + exports: { fn() { return 42; } }, }); let esmImpl = await import('node:readline'); diff --git a/lib/internal/test_runner/mock/mock.js b/lib/internal/test_runner/mock/mock.js index 1af24c77a10731..2f85e05fab060a 100644 --- a/lib/internal/test_runner/mock/mock.js +++ b/lib/internal/test_runner/mock/mock.js @@ -61,6 +61,8 @@ const kSupportedFormats = [ 'module', ]; let sharedModuleState; +let warnedLegacyDefaultExport; +let warnedLegacyNamedExports; const { hooks: mockHooks, mocks, @@ -627,14 +629,11 @@ class MockTracker { debug('module mock entry, specifier = "%s", options = %o', specifier, options); const { - cache = false, - namedExports = kEmptyObject, + cache, defaultExport, - } = options; - const hasDefaultExport = 'defaultExport' in options; - - validateBoolean(cache, 'options.cache'); - validateObject(namedExports, 'options.namedExports'); + hasDefaultExport, + namedExports, + } = normalizeModuleMockOptions(options); const sharedState = setupSharedModuleState(); const mockSpecifier = StringPrototypeStartsWith(specifier, 'node:') ? @@ -816,6 +815,90 @@ class MockTracker { } } +function normalizeModuleMockOptions(options) { + const { cache = false } = options; + validateBoolean(cache, 'options.cache'); + + const moduleExports = { + __proto__: null, + }; + + if ('exports' in options) { + validateObject(options.exports, 'options.exports'); + copyOwnProperties(options.exports, moduleExports); + } + + if ('namedExports' in options) { + validateObject(options.namedExports, 'options.namedExports'); + emitLegacyMockOptionWarning('namedExports'); + copyOwnProperties(options.namedExports, moduleExports); + } + + if ('defaultExport' in options) { + emitLegacyMockOptionWarning('defaultExport'); + moduleExports.default = options.defaultExport; + } + + const namedExports = { __proto__: null }; + const exportNames = ObjectKeys(moduleExports); + + for (let i = 0; i < exportNames.length; ++i) { + const name = exportNames[i]; + + if (name === 'default') { + continue; + } + + const descriptor = ObjectGetOwnPropertyDescriptor(moduleExports, name); + ObjectDefineProperty(namedExports, name, descriptor); + } + + return { + __proto__: null, + cache, + defaultExport: moduleExports.default, + hasDefaultExport: 'default' in moduleExports, + namedExports, + }; +} + +function emitLegacyMockOptionWarning(option) { + switch (option) { + case 'defaultExport': + if (warnedLegacyDefaultExport === true) { + return; + } + warnedLegacyDefaultExport = true; + process.emitWarning( + 'mock.module(): options.defaultExport is deprecated. ' + + 'Use options.exports.default instead.', + 'DeprecationWarning', + ); + break; + case 'namedExports': + if (warnedLegacyNamedExports === true) { + return; + } + warnedLegacyNamedExports = true; + process.emitWarning( + 'mock.module(): options.namedExports is deprecated. ' + + 'Use options.exports instead.', + 'DeprecationWarning', + ); + break; + } +} + +function copyOwnProperties(from, to) { + const keys = ObjectKeys(from); + + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]; + const descriptor = ObjectGetOwnPropertyDescriptor(from, key); + ObjectDefineProperty(to, key, descriptor); + } +} + function setupSharedModuleState() { if (sharedModuleState === undefined) { const { mock } = require('test'); diff --git a/test/fixtures/test-runner/output/coverage-with-mock.mjs b/test/fixtures/test-runner/output/coverage-with-mock.mjs index 5d5b2b14f66a95..e3b8fcc473f450 100644 --- a/test/fixtures/test-runner/output/coverage-with-mock.mjs +++ b/test/fixtures/test-runner/output/coverage-with-mock.mjs @@ -2,7 +2,7 @@ import { describe, it, mock } from 'node:test'; describe('module test with mock', async () => { mock.module('../coverage-with-mock/sum.js', { - namedExports: { + exports: { sum: (a, b) => 1, getData: () => ({}), }, diff --git a/test/fixtures/test-runner/output/typescript-coverage.mts b/test/fixtures/test-runner/output/typescript-coverage.mts index c26ddcac9b33f9..5498d5f83831a1 100644 --- a/test/fixtures/test-runner/output/typescript-coverage.mts +++ b/test/fixtures/test-runner/output/typescript-coverage.mts @@ -10,8 +10,10 @@ describe('foo', { concurrency: true }, () => { .then(({ default: _, ...rest }) => rest); mock.module('../coverage/bar.mts', { - defaultExport: barMock, - namedExports: barNamedExports, + exports: { + ...barNamedExports, + default: barMock, + }, }); ({ foo } = await import('../coverage/foo.mts')); diff --git a/test/fixtures/test-runner/output/typescript-coverage.snapshot b/test/fixtures/test-runner/output/typescript-coverage.snapshot index 25546c9a5d0060..d31a59c5be61a3 100644 --- a/test/fixtures/test-runner/output/typescript-coverage.snapshot +++ b/test/fixtures/test-runner/output/typescript-coverage.snapshot @@ -34,6 +34,6 @@ ok 1 - foo # output | | | | # typescript-coverage.mts | 100.00 | 100.00 | 100.00 | # ---------------------------------------------------------------------------- -# all files | 93.55 | 100.00 | 85.71 | +# all files | 93.94 | 100.00 | 85.71 | # ---------------------------------------------------------------------------- # end of coverage report diff --git a/test/parallel/test-runner-module-mocking.js b/test/parallel/test-runner-module-mocking.js index dcb6f84597fe71..fb2049f19a12de 100644 --- a/test/parallel/test-runner-module-mocking.js +++ b/test/parallel/test-runner-module-mocking.js @@ -39,6 +39,33 @@ test('input validation', async (t) => { }); }, { code: 'ERR_INVALID_ARG_TYPE' }); }); + + await t.test('throws if exports is not an object', async (t) => { + assert.throws(() => { + t.mock.module(__filename, { + exports: null, + }); + }, { code: 'ERR_INVALID_ARG_TYPE' }); + }); + + await t.test('allows exports to be used with legacy options', async (t) => { + const fixturePath = fixtures.path('module-mocking', 'basic-cjs.js'); + const fixture = pathToFileURL(fixturePath); + + t.mock.module(fixture, { + exports: { value: 'from exports' }, + namedExports: { value: 'from namedExports' }, + defaultExport: { from: 'defaultExport' }, + }); + + const cjsMock = require(fixturePath); + const esmMock = await import(fixture); + + assert.strictEqual(cjsMock.value, 'from namedExports'); + assert.strictEqual(cjsMock.from, 'defaultExport'); + assert.strictEqual(esmMock.value, 'from namedExports'); + assert.strictEqual(esmMock.default.from, 'defaultExport'); + }); }); test('core module mocking with namedExports option', async (t) => { @@ -517,42 +544,33 @@ test('mocks can be restored independently', async (t) => { assert.strictEqual(esmImpl.fn, undefined); }); -test('core module mocks can be used by both module systems', async (t) => { - const coreMock = t.mock.module('readline', { - namedExports: { fn() { return 42; } }, - }); +async function assertCoreModuleMockWorksInBothModuleSystems(t, specifier, options) { + const coreMock = t.mock.module(specifier, options); - let esmImpl = await import('readline'); - let cjsImpl = require('readline'); + let esmImpl = await import(specifier); + let cjsImpl = require(specifier); assert.strictEqual(esmImpl.fn(), 42); assert.strictEqual(cjsImpl.fn(), 42); coreMock.restore(); - esmImpl = await import('readline'); - cjsImpl = require('readline'); + esmImpl = await import(specifier); + cjsImpl = require(specifier); assert.strictEqual(typeof esmImpl.cursorTo, 'function'); assert.strictEqual(typeof cjsImpl.cursorTo, 'function'); +} + +test('core module mocks can be used by both module systems', async (t) => { + await assertCoreModuleMockWorksInBothModuleSystems(t, 'readline', { + namedExports: { fn() { return 42; } }, + }); }); test('node:- core module mocks can be used by both module systems', async (t) => { - const coreMock = t.mock.module('node:readline', { + await assertCoreModuleMockWorksInBothModuleSystems(t, 'node:readline', { namedExports: { fn() { return 42; } }, }); - - let esmImpl = await import('node:readline'); - let cjsImpl = require('node:readline'); - - assert.strictEqual(esmImpl.fn(), 42); - assert.strictEqual(cjsImpl.fn(), 42); - - coreMock.restore(); - esmImpl = await import('node:readline'); - cjsImpl = require('node:readline'); - - assert.strictEqual(typeof esmImpl.cursorTo, 'function'); - assert.strictEqual(typeof cjsImpl.cursorTo, 'function'); }); test('CJS mocks can be used by both module systems', async (t) => { @@ -666,6 +684,55 @@ test('defaultExports work with ESM mocks in both module systems', async (t) => { assert.strictEqual(require(fixturePath), defaultExport); }); +test('exports option works with core module mocks in both module systems', async (t) => { + await assertCoreModuleMockWorksInBothModuleSystems(t, 'readline', { + exports: { fn() { return 42; } }, + }); +}); + +test('exports option supports default for CJS mocks in both module systems', async (t) => { + const fixturePath = fixtures.path('module-mocking', 'basic-cjs.js'); + const fixture = pathToFileURL(fixturePath); + const defaultExport = { val1: 5, val2: 3 }; + + t.mock.module(fixture, { + exports: { + default: defaultExport, + val1: 'mock value', + }, + }); + + const cjsMock = require(fixturePath); + const esmMock = await import(fixture); + + assert.strictEqual(cjsMock, defaultExport); + assert.strictEqual(esmMock.default, defaultExport); + assert.strictEqual(cjsMock.val1, 'mock value'); + assert.strictEqual(esmMock.val1, 'mock value'); + assert.strictEqual(cjsMock.val2, 3); +}); + +test('exports option supports default for ESM mocks in both module systems', async (t) => { + const fixturePath = fixtures.path('module-mocking', 'basic-esm.mjs'); + const fixture = pathToFileURL(fixturePath); + const defaultExport = { mocked: true }; + + t.mock.module(fixture, { + exports: { + default: defaultExport, + val1: 'mock value', + }, + }); + + const esmMock = await import(fixture); + const cjsMock = require(fixturePath); + + assert.strictEqual(esmMock.default, defaultExport); + assert.strictEqual(esmMock.val1, 'mock value'); + assert.strictEqual(cjsMock, defaultExport); + assert.strictEqual(cjsMock.val1, 'mock value'); +}); + test('wrong import syntax should throw error after module mocking', async () => { const { stdout, stderr, code } = await common.spawnPromisified( process.execPath, From f956d87ef6accc6fa9dda4459cce627cd4aff6b2 Mon Sep 17 00:00:00 2001 From: sangwook Date: Thu, 26 Feb 2026 22:43:04 +0900 Subject: [PATCH 2/4] doc: improve mock.module example clarity Rename the named export in the mock.module() example from `fn` to `foo` for improved readability. --- doc/api/test.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/api/test.md b/doc/api/test.md index 223ccf53267078..0b02cc8b2b26d7 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -2465,10 +2465,10 @@ The following example demonstrates how a mock is created for a module. ```js test('mocks a builtin module in both module systems', async (t) => { - // Create a mock of 'node:readline' with a named export named 'fn', which + // Create a mock of 'node:readline' with a named export named 'foo', which // does not exist in the original 'node:readline' module. const mock = t.mock.module('node:readline', { - exports: { fn() { return 42; } }, + exports: { foo: () => 42 }, }); let esmImpl = await import('node:readline'); From 13b13b8996bc89b4b5ffaafca8f4f7c57638d9ad Mon Sep 17 00:00:00 2001 From: sangwook Date: Thu, 26 Feb 2026 22:43:39 +0900 Subject: [PATCH 3/4] test_runner: simplify mock.module normalization - Unify internal export representation into a single `moduleExports` object, removing the split into `defaultExport`, `hasDefaultExport`, and `namedExports`. - DRY up `emitLegacyMockOptionWarning` with a lookup map instead of a switch statement with separate flag variables. - Use `ObjectDefineProperty` for `defaultExport` option to be consistent with descriptor copying in `namedExports`. --- lib/internal/test_runner/mock/loader.js | 8 +- lib/internal/test_runner/mock/mock.js | 101 ++++++++---------------- 2 files changed, 36 insertions(+), 73 deletions(-) diff --git a/lib/internal/test_runner/mock/loader.js b/lib/internal/test_runner/mock/loader.js index a7a22539be3093..d64ffdfa096559 100644 --- a/lib/internal/test_runner/mock/loader.js +++ b/lib/internal/test_runner/mock/loader.js @@ -113,10 +113,10 @@ function defaultExportSource(useESM, hasDefaultExport) { if (!hasDefaultExport) { return ''; } else if (useESM) { - return 'export default $__exports.defaultExport;'; + return 'export default $__exports.moduleExports.default;'; } - return 'module.exports = $__exports.defaultExport;'; + return 'module.exports = $__exports.moduleExports.default;'; } function namedExportsSource(useESM, exportNames) { @@ -134,9 +134,9 @@ if (module.exports === null || typeof module.exports !== 'object') { const name = exportNames[i]; if (useESM) { - source += `export let ${name} = $__exports.namedExports[${JSONStringify(name)}];\n`; + source += `export let ${name} = $__exports.moduleExports[${JSONStringify(name)}];\n`; } else { - source += `module.exports[${JSONStringify(name)}] = $__exports.namedExports[${JSONStringify(name)}];\n`; + source += `module.exports[${JSONStringify(name)}] = $__exports.moduleExports[${JSONStringify(name)}];\n`; } } diff --git a/lib/internal/test_runner/mock/mock.js b/lib/internal/test_runner/mock/mock.js index 2f85e05fab060a..0e38168d0ca3ad 100644 --- a/lib/internal/test_runner/mock/mock.js +++ b/lib/internal/test_runner/mock/mock.js @@ -1,5 +1,6 @@ 'use strict'; const { + ArrayPrototypeFilter, ArrayPrototypePush, ArrayPrototypeSlice, Error, @@ -61,8 +62,12 @@ const kSupportedFormats = [ 'module', ]; let sharedModuleState; -let warnedLegacyDefaultExport; -let warnedLegacyNamedExports; +const warnedLegacyOptions = { __proto__: null }; +const kLegacyOptionReplacements = { + __proto__: null, + defaultExport: 'options.exports.default', + namedExports: 'options.exports', +}; const { hooks: mockHooks, mocks, @@ -187,20 +192,16 @@ class MockModuleContext { baseURL, cache, caller, - defaultExport, format, fullPath, - hasDefaultExport, - namedExports, + moduleExports, sharedState, specifier, }) { const config = { __proto__: null, cache, - defaultExport, - hasDefaultExport, - namedExports, + moduleExports, caller, }; @@ -232,8 +233,8 @@ class MockModuleContext { __proto__: null, url: baseURL, cache, - exportNames: ObjectKeys(namedExports), - hasDefaultExport, + exportNames: ArrayPrototypeFilter(ObjectKeys(moduleExports), (k) => k !== 'default'), + hasDefaultExport: 'default' in moduleExports, format, localVersion, active: true, @@ -243,8 +244,7 @@ class MockModuleContext { delete Module._cache[fullPath]; sharedState.mockExports.set(baseURL, { __proto__: null, - defaultExport, - namedExports, + moduleExports, }); } @@ -630,9 +630,7 @@ class MockTracker { const { cache, - defaultExport, - hasDefaultExport, - namedExports, + moduleExports, } = normalizeModuleMockOptions(options); const sharedState = setupSharedModuleState(); @@ -672,11 +670,9 @@ class MockTracker { baseURL: baseURL.href, cache, caller, - defaultExport, format, fullPath, - hasDefaultExport, - namedExports, + moduleExports, sharedState, specifier: mockSpecifier, }); @@ -819,9 +815,7 @@ function normalizeModuleMockOptions(options) { const { cache = false } = options; validateBoolean(cache, 'options.cache'); - const moduleExports = { - __proto__: null, - }; + const moduleExports = { __proto__: null }; if ('exports' in options) { validateObject(options.exports, 'options.exports'); @@ -836,57 +830,27 @@ function normalizeModuleMockOptions(options) { if ('defaultExport' in options) { emitLegacyMockOptionWarning('defaultExport'); - moduleExports.default = options.defaultExport; - } - - const namedExports = { __proto__: null }; - const exportNames = ObjectKeys(moduleExports); - - for (let i = 0; i < exportNames.length; ++i) { - const name = exportNames[i]; - - if (name === 'default') { - continue; - } - - const descriptor = ObjectGetOwnPropertyDescriptor(moduleExports, name); - ObjectDefineProperty(namedExports, name, descriptor); + ObjectDefineProperty(moduleExports, 'default', { + __proto__: null, + ...ObjectGetOwnPropertyDescriptor(options, 'defaultExport'), + }); } return { __proto__: null, cache, - defaultExport: moduleExports.default, - hasDefaultExport: 'default' in moduleExports, - namedExports, + moduleExports, }; } function emitLegacyMockOptionWarning(option) { - switch (option) { - case 'defaultExport': - if (warnedLegacyDefaultExport === true) { - return; - } - warnedLegacyDefaultExport = true; - process.emitWarning( - 'mock.module(): options.defaultExport is deprecated. ' + - 'Use options.exports.default instead.', - 'DeprecationWarning', - ); - break; - case 'namedExports': - if (warnedLegacyNamedExports === true) { - return; - } - warnedLegacyNamedExports = true; - process.emitWarning( - 'mock.module(): options.namedExports is deprecated. ' + - 'Use options.exports instead.', - 'DeprecationWarning', - ); - break; - } + if (warnedLegacyOptions[option]) return; + warnedLegacyOptions[option] = true; + process.emitWarning( + `mock.module(): options.${option} is deprecated. ` + + `Use ${kLegacyOptionReplacements[option]} instead.`, + 'DeprecationWarning', + ); } function copyOwnProperties(from, to) { @@ -938,9 +902,7 @@ function cjsMockModuleLoad(request, parent, isMain) { const { cache, caller, - defaultExport, - hasDefaultExport, - namedExports, + moduleExports, } = config; if (cache && Module._cache[resolved]) { @@ -949,9 +911,10 @@ function cjsMockModuleLoad(request, parent, isMain) { return Module._cache[resolved].exports; } + const hasDefaultExport = 'default' in moduleExports; // eslint-disable-next-line node-core/set-proto-to-null-in-object - const modExports = hasDefaultExport ? defaultExport : {}; - const exportNames = ObjectKeys(namedExports); + const modExports = hasDefaultExport ? moduleExports.default : {}; + const exportNames = ArrayPrototypeFilter(ObjectKeys(moduleExports), (k) => k !== 'default'); if ((typeof modExports !== 'object' || modExports === null) && exportNames.length > 0) { @@ -961,7 +924,7 @@ function cjsMockModuleLoad(request, parent, isMain) { for (let i = 0; i < exportNames.length; ++i) { const name = exportNames[i]; - const descriptor = ObjectGetOwnPropertyDescriptor(namedExports, name); + const descriptor = ObjectGetOwnPropertyDescriptor(moduleExports, name); ObjectDefineProperty(modExports, name, descriptor); } From 60f2d68ad2ae1df3dccc4297e5941d955c788ef5 Mon Sep 17 00:00:00 2001 From: sangwook Date: Fri, 27 Feb 2026 07:14:20 +0900 Subject: [PATCH 4/4] test_runner: improve mock.module options handling This commit addresses several feedback items for mock.module(): - Fixes a potential prototype pollution vulnerability by using ObjectAssign({ __proto__: null }, ...) for property descriptors. - Migrates legacy option warnings to use the internal deprecateProperty() utility for better consistency. - Updates documentation to use "a later version" instead of "a future major release" for deprecation timelines, as the feature is experimental. - Adds comprehensive test cases to ensure getter properties in mock options are correctly preserved across both module systems. --- doc/api/test.md | 4 +-- lib/internal/test_runner/mock/mock.js | 39 ++++++++++----------- test/parallel/test-runner-module-mocking.js | 37 +++++++++++++++++++ 3 files changed, 57 insertions(+), 23 deletions(-) diff --git a/doc/api/test.md b/doc/api/test.md index 0b02cc8b2b26d7..9a68c97c8cf29b 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -2445,14 +2445,14 @@ changes: export. If the mock is a CommonJS or builtin module, this setting is used as the value of `module.exports`. If this value is not provided, CJS and builtin mocks use an empty object as the value of `module.exports`. - This option is deprecated and will be removed in a future major release. + This option is deprecated and will be removed in a later version. Prefer `options.exports.default`. * `namedExports` {Object} An optional object whose keys and values are used to create the named exports of the mock module. If the mock is a CommonJS or builtin module, these values are copied onto `module.exports`. Therefore, if a mock is created with both named exports and a non-object default export, the mock will throw an exception when used as a CJS or builtin module. - This option is deprecated and will be removed in a future major release. + This option is deprecated and will be removed in a later version. Prefer `options.exports`. * Returns: {MockModuleContext} An object that can be used to manipulate the mock. diff --git a/lib/internal/test_runner/mock/mock.js b/lib/internal/test_runner/mock/mock.js index 0e38168d0ca3ad..ce0fd7ed92f43a 100644 --- a/lib/internal/test_runner/mock/mock.js +++ b/lib/internal/test_runner/mock/mock.js @@ -6,6 +6,7 @@ const { Error, FunctionPrototypeBind, FunctionPrototypeCall, + ObjectAssign, ObjectDefineProperty, ObjectGetOwnPropertyDescriptor, ObjectGetPrototypeOf, @@ -34,6 +35,7 @@ const { URLParse, } = require('internal/url'); const { + deprecateProperty, emitExperimentalWarning, getStructuredStack, kEmptyObject, @@ -62,12 +64,14 @@ const kSupportedFormats = [ 'module', ]; let sharedModuleState; -const warnedLegacyOptions = { __proto__: null }; -const kLegacyOptionReplacements = { - __proto__: null, - defaultExport: 'options.exports.default', - namedExports: 'options.exports', -}; +const deprecateNamedExports = deprecateProperty( + 'namedExports', + 'mock.module(): options.namedExports is deprecated. Use options.exports instead.', +); +const deprecateDefaultExport = deprecateProperty( + 'defaultExport', + 'mock.module(): options.defaultExport is deprecated. Use options.exports.default instead.', +); const { hooks: mockHooks, mocks, @@ -815,6 +819,9 @@ function normalizeModuleMockOptions(options) { const { cache = false } = options; validateBoolean(cache, 'options.cache'); + deprecateNamedExports(options); + deprecateDefaultExport(options); + const moduleExports = { __proto__: null }; if ('exports' in options) { @@ -824,16 +831,15 @@ function normalizeModuleMockOptions(options) { if ('namedExports' in options) { validateObject(options.namedExports, 'options.namedExports'); - emitLegacyMockOptionWarning('namedExports'); copyOwnProperties(options.namedExports, moduleExports); } if ('defaultExport' in options) { - emitLegacyMockOptionWarning('defaultExport'); - ObjectDefineProperty(moduleExports, 'default', { - __proto__: null, - ...ObjectGetOwnPropertyDescriptor(options, 'defaultExport'), - }); + ObjectDefineProperty( + moduleExports, + 'default', + ObjectAssign({ __proto__: null }, ObjectGetOwnPropertyDescriptor(options, 'defaultExport')), + ); } return { @@ -843,15 +849,6 @@ function normalizeModuleMockOptions(options) { }; } -function emitLegacyMockOptionWarning(option) { - if (warnedLegacyOptions[option]) return; - warnedLegacyOptions[option] = true; - process.emitWarning( - `mock.module(): options.${option} is deprecated. ` + - `Use ${kLegacyOptionReplacements[option]} instead.`, - 'DeprecationWarning', - ); -} function copyOwnProperties(from, to) { const keys = ObjectKeys(from); diff --git a/test/parallel/test-runner-module-mocking.js b/test/parallel/test-runner-module-mocking.js index fb2049f19a12de..98fed208444eaf 100644 --- a/test/parallel/test-runner-module-mocking.js +++ b/test/parallel/test-runner-module-mocking.js @@ -690,6 +690,43 @@ test('exports option works with core module mocks in both module systems', async }); }); +async function assertGetterMockWorksInBothSystems(t, mockOptionsFactory) { + const fixturePath = fixtures.path('module-mocking', 'basic-esm.mjs'); + const fixture = pathToFileURL(fixturePath); + const original = await import(fixture); + let getterCalls = 0; + + assert.strictEqual(original.string, 'original esm string'); + + const options = mockOptionsFactory(() => { + getterCalls++; + return { mocked: true }; + }); + + t.mock.module(`${fixture}`, options); + + assert.deepStrictEqual((await import(fixture)).default, { mocked: true }); + assert.deepStrictEqual(require(fixturePath), { mocked: true }); + assert.strictEqual(getterCalls, 2); +} + +test('defaultExports getter works in both module systems', async (t) => { + await assertGetterMockWorksInBothSystems(t, (getter) => ({ + get defaultExport() { + return getter(); + }, + })); +}); + +test('exports.default getter works in both module systems', async (t) => { + await assertGetterMockWorksInBothSystems(t, (getter) => ({ + exports: { + get default() { + return getter(); + }, + }, + })); +}); test('exports option supports default for CJS mocks in both module systems', async (t) => { const fixturePath = fixtures.path('module-mocking', 'basic-cjs.js'); const fixture = pathToFileURL(fixturePath);