diff --git a/doc/api/test.md b/doc/api/test.md index 40fb08d0d5b181..9a68c97c8cf29b 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 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 later version. + 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 @@ -2452,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', { - namedExports: { fn() { return 42; } }, + exports: { foo: () => 42 }, }); let esmImpl = await import('node:readline'); 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 1af24c77a10731..ce0fd7ed92f43a 100644 --- a/lib/internal/test_runner/mock/mock.js +++ b/lib/internal/test_runner/mock/mock.js @@ -1,10 +1,12 @@ 'use strict'; const { + ArrayPrototypeFilter, ArrayPrototypePush, ArrayPrototypeSlice, Error, FunctionPrototypeBind, FunctionPrototypeCall, + ObjectAssign, ObjectDefineProperty, ObjectGetOwnPropertyDescriptor, ObjectGetPrototypeOf, @@ -33,6 +35,7 @@ const { URLParse, } = require('internal/url'); const { + deprecateProperty, emitExperimentalWarning, getStructuredStack, kEmptyObject, @@ -61,6 +64,14 @@ const kSupportedFormats = [ 'module', ]; let sharedModuleState; +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, @@ -185,20 +196,16 @@ class MockModuleContext { baseURL, cache, caller, - defaultExport, format, fullPath, - hasDefaultExport, - namedExports, + moduleExports, sharedState, specifier, }) { const config = { __proto__: null, cache, - defaultExport, - hasDefaultExport, - namedExports, + moduleExports, caller, }; @@ -230,8 +237,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, @@ -241,8 +248,7 @@ class MockModuleContext { delete Module._cache[fullPath]; sharedState.mockExports.set(baseURL, { __proto__: null, - defaultExport, - namedExports, + moduleExports, }); } @@ -627,14 +633,9 @@ class MockTracker { debug('module mock entry, specifier = "%s", options = %o', specifier, options); const { - cache = false, - namedExports = kEmptyObject, - defaultExport, - } = options; - const hasDefaultExport = 'defaultExport' in options; - - validateBoolean(cache, 'options.cache'); - validateObject(namedExports, 'options.namedExports'); + cache, + moduleExports, + } = normalizeModuleMockOptions(options); const sharedState = setupSharedModuleState(); const mockSpecifier = StringPrototypeStartsWith(specifier, 'node:') ? @@ -673,11 +674,9 @@ class MockTracker { baseURL: baseURL.href, cache, caller, - defaultExport, format, fullPath, - hasDefaultExport, - namedExports, + moduleExports, sharedState, specifier: mockSpecifier, }); @@ -816,6 +815,51 @@ class MockTracker { } } +function normalizeModuleMockOptions(options) { + const { cache = false } = options; + validateBoolean(cache, 'options.cache'); + + deprecateNamedExports(options); + deprecateDefaultExport(options); + + 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'); + copyOwnProperties(options.namedExports, moduleExports); + } + + if ('defaultExport' in options) { + ObjectDefineProperty( + moduleExports, + 'default', + ObjectAssign({ __proto__: null }, ObjectGetOwnPropertyDescriptor(options, 'defaultExport')), + ); + } + + return { + __proto__: null, + cache, + moduleExports, + }; +} + + +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'); @@ -855,9 +899,7 @@ function cjsMockModuleLoad(request, parent, isMain) { const { cache, caller, - defaultExport, - hasDefaultExport, - namedExports, + moduleExports, } = config; if (cache && Module._cache[resolved]) { @@ -866,9 +908,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) { @@ -878,7 +921,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); } 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..98fed208444eaf 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,92 @@ 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; } }, + }); +}); + +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); + 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,