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,