Skip to content

Commit fa99ada

Browse files
committed
test_runner: add exports option to mock.module
Introduce options.exports for mock.module and normalize all option shapes through a shared exports path. Keep legacy defaultExport and namedExports working as aliases, support mixed usage with deterministic precedence, and mark legacy options as deprecated in the docs while extending coverage. Refs: #58443
1 parent c2a0353 commit fa99ada

File tree

3 files changed

+162
-30
lines changed

3 files changed

+162
-30
lines changed

doc/api/test.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2431,16 +2431,29 @@ changes:
24312431
generates a new mock module. If `true`, subsequent calls will return the same
24322432
module mock, and the mock module is inserted into the CommonJS cache.
24332433
**Default:** false.
2434+
* `exports` {Object} Optional mocked exports. The `default` property, if
2435+
provided, is used as the mocked module's default export. All other own
2436+
enumerable properties are used as named exports.
2437+
* If the mock is a CommonJS or builtin module, `exports.default` is used as
2438+
the value of `module.exports`.
2439+
* If `exports.default` is not provided for a CommonJS or builtin mock,
2440+
`module.exports` defaults to an empty object.
2441+
* If named exports are provided with a non-object default export, the mock
2442+
throws an exception when used as a CommonJS or builtin module.
24342443
* `defaultExport` {any} An optional value used as the mocked module's default
24352444
export. If this value is not provided, ESM mocks do not include a default
24362445
export. If the mock is a CommonJS or builtin module, this setting is used as
24372446
the value of `module.exports`. If this value is not provided, CJS and builtin
24382447
mocks use an empty object as the value of `module.exports`.
2448+
This option is deprecated and will be removed in a future major release.
2449+
Prefer `options.exports.default`.
24392450
* `namedExports` {Object} An optional object whose keys and values are used to
24402451
create the named exports of the mock module. If the mock is a CommonJS or
24412452
builtin module, these values are copied onto `module.exports`. Therefore, if a
24422453
mock is created with both named exports and a non-object default export, the
24432454
mock will throw an exception when used as a CJS or builtin module.
2455+
This option is deprecated and will be removed in a future major release.
2456+
Prefer `options.exports`.
24442457
* Returns: {MockModuleContext} An object that can be used to manipulate the mock.
24452458

24462459
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) => {
24552468
// Create a mock of 'node:readline' with a named export named 'fn', which
24562469
// does not exist in the original 'node:readline' module.
24572470
const mock = t.mock.module('node:readline', {
2458-
namedExports: { fn() { return 42; } },
2471+
exports: { fn() { return 42; } },
24592472
});
24602473

24612474
let esmImpl = await import('node:readline');

lib/internal/test_runner/mock/mock.js

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -627,14 +627,11 @@ class MockTracker {
627627
debug('module mock entry, specifier = "%s", options = %o', specifier, options);
628628

629629
const {
630-
cache = false,
631-
namedExports = kEmptyObject,
630+
cache,
632631
defaultExport,
633-
} = options;
634-
const hasDefaultExport = 'defaultExport' in options;
635-
636-
validateBoolean(cache, 'options.cache');
637-
validateObject(namedExports, 'options.namedExports');
632+
hasDefaultExport,
633+
namedExports,
634+
} = normalizeModuleMockOptions(options);
638635

639636
const sharedState = setupSharedModuleState();
640637
const mockSpecifier = StringPrototypeStartsWith(specifier, 'node:') ?
@@ -816,6 +813,61 @@ class MockTracker {
816813
}
817814
}
818815

816+
function normalizeModuleMockOptions(options) {
817+
const { cache = false } = options;
818+
validateBoolean(cache, 'options.cache');
819+
820+
const moduleExports = {
821+
__proto__: null,
822+
};
823+
824+
if ('exports' in options) {
825+
validateObject(options.exports, 'options.exports');
826+
copyOwnProperties(options.exports, moduleExports);
827+
}
828+
829+
if ('namedExports' in options) {
830+
validateObject(options.namedExports, 'options.namedExports');
831+
copyOwnProperties(options.namedExports, moduleExports);
832+
}
833+
834+
if ('defaultExport' in options) {
835+
moduleExports.default = options.defaultExport;
836+
}
837+
838+
const namedExports = { __proto__: null };
839+
const exportNames = ObjectKeys(moduleExports);
840+
841+
for (let i = 0; i < exportNames.length; ++i) {
842+
const name = exportNames[i];
843+
844+
if (name === 'default') {
845+
continue;
846+
}
847+
848+
const descriptor = ObjectGetOwnPropertyDescriptor(moduleExports, name);
849+
ObjectDefineProperty(namedExports, name, descriptor);
850+
}
851+
852+
return {
853+
__proto__: null,
854+
cache,
855+
defaultExport: moduleExports.default,
856+
hasDefaultExport: 'default' in moduleExports,
857+
namedExports,
858+
};
859+
}
860+
861+
function copyOwnProperties(from, to) {
862+
const keys = ObjectKeys(from);
863+
864+
for (let i = 0; i < keys.length; ++i) {
865+
const key = keys[i];
866+
const descriptor = ObjectGetOwnPropertyDescriptor(from, key);
867+
ObjectDefineProperty(to, key, descriptor);
868+
}
869+
}
870+
819871
function setupSharedModuleState() {
820872
if (sharedModuleState === undefined) {
821873
const { mock } = require('test');

test/parallel/test-runner-module-mocking.js

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,33 @@ test('input validation', async (t) => {
3939
});
4040
}, { code: 'ERR_INVALID_ARG_TYPE' });
4141
});
42+
43+
await t.test('throws if exports is not an object', async (t) => {
44+
assert.throws(() => {
45+
t.mock.module(__filename, {
46+
exports: null,
47+
});
48+
}, { code: 'ERR_INVALID_ARG_TYPE' });
49+
});
50+
51+
await t.test('allows exports to be used with legacy options', async (t) => {
52+
const fixturePath = fixtures.path('module-mocking', 'basic-cjs.js');
53+
const fixture = pathToFileURL(fixturePath);
54+
55+
t.mock.module(fixture, {
56+
exports: { value: 'from exports' },
57+
namedExports: { value: 'from namedExports' },
58+
defaultExport: { from: 'defaultExport' },
59+
});
60+
61+
const cjsMock = require(fixturePath);
62+
const esmMock = await import(fixture);
63+
64+
assert.strictEqual(cjsMock.value, 'from namedExports');
65+
assert.strictEqual(cjsMock.from, 'defaultExport');
66+
assert.strictEqual(esmMock.value, 'from namedExports');
67+
assert.strictEqual(esmMock.default.from, 'defaultExport');
68+
});
4269
});
4370

4471
test('core module mocking with namedExports option', async (t) => {
@@ -517,42 +544,33 @@ test('mocks can be restored independently', async (t) => {
517544
assert.strictEqual(esmImpl.fn, undefined);
518545
});
519546

520-
test('core module mocks can be used by both module systems', async (t) => {
521-
const coreMock = t.mock.module('readline', {
522-
namedExports: { fn() { return 42; } },
523-
});
547+
async function assertCoreModuleMockWorksInBothModuleSystems(t, specifier, options) {
548+
const coreMock = t.mock.module(specifier, options);
524549

525-
let esmImpl = await import('readline');
526-
let cjsImpl = require('readline');
550+
let esmImpl = await import(specifier);
551+
let cjsImpl = require(specifier);
527552

528553
assert.strictEqual(esmImpl.fn(), 42);
529554
assert.strictEqual(cjsImpl.fn(), 42);
530555

531556
coreMock.restore();
532-
esmImpl = await import('readline');
533-
cjsImpl = require('readline');
557+
esmImpl = await import(specifier);
558+
cjsImpl = require(specifier);
534559

535560
assert.strictEqual(typeof esmImpl.cursorTo, 'function');
536561
assert.strictEqual(typeof cjsImpl.cursorTo, 'function');
562+
}
563+
564+
test('core module mocks can be used by both module systems', async (t) => {
565+
await assertCoreModuleMockWorksInBothModuleSystems(t, 'readline', {
566+
namedExports: { fn() { return 42; } },
567+
});
537568
});
538569

539570
test('node:- core module mocks can be used by both module systems', async (t) => {
540-
const coreMock = t.mock.module('node:readline', {
571+
await assertCoreModuleMockWorksInBothModuleSystems(t, 'node:readline', {
541572
namedExports: { fn() { return 42; } },
542573
});
543-
544-
let esmImpl = await import('node:readline');
545-
let cjsImpl = require('node:readline');
546-
547-
assert.strictEqual(esmImpl.fn(), 42);
548-
assert.strictEqual(cjsImpl.fn(), 42);
549-
550-
coreMock.restore();
551-
esmImpl = await import('node:readline');
552-
cjsImpl = require('node:readline');
553-
554-
assert.strictEqual(typeof esmImpl.cursorTo, 'function');
555-
assert.strictEqual(typeof cjsImpl.cursorTo, 'function');
556574
});
557575

558576
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) => {
666684
assert.strictEqual(require(fixturePath), defaultExport);
667685
});
668686

687+
test('exports option works with core module mocks in both module systems', async (t) => {
688+
await assertCoreModuleMockWorksInBothModuleSystems(t, 'readline', {
689+
exports: { fn() { return 42; } },
690+
});
691+
});
692+
693+
test('exports option supports default for CJS mocks in both module systems', async (t) => {
694+
const fixturePath = fixtures.path('module-mocking', 'basic-cjs.js');
695+
const fixture = pathToFileURL(fixturePath);
696+
const defaultExport = { val1: 5, val2: 3 };
697+
698+
t.mock.module(fixture, {
699+
exports: {
700+
default: defaultExport,
701+
val1: 'mock value',
702+
},
703+
});
704+
705+
const cjsMock = require(fixturePath);
706+
const esmMock = await import(fixture);
707+
708+
assert.strictEqual(cjsMock, defaultExport);
709+
assert.strictEqual(esmMock.default, defaultExport);
710+
assert.strictEqual(cjsMock.val1, 'mock value');
711+
assert.strictEqual(esmMock.val1, 'mock value');
712+
assert.strictEqual(cjsMock.val2, 3);
713+
});
714+
715+
test('exports option supports default for ESM mocks in both module systems', async (t) => {
716+
const fixturePath = fixtures.path('module-mocking', 'basic-esm.mjs');
717+
const fixture = pathToFileURL(fixturePath);
718+
const defaultExport = { mocked: true };
719+
720+
t.mock.module(fixture, {
721+
exports: {
722+
default: defaultExport,
723+
val1: 'mock value',
724+
},
725+
});
726+
727+
const esmMock = await import(fixture);
728+
const cjsMock = require(fixturePath);
729+
730+
assert.strictEqual(esmMock.default, defaultExport);
731+
assert.strictEqual(esmMock.val1, 'mock value');
732+
assert.strictEqual(cjsMock, defaultExport);
733+
assert.strictEqual(cjsMock.val1, 'mock value');
734+
});
735+
669736
test('wrong import syntax should throw error after module mocking', async () => {
670737
const { stdout, stderr, code } = await common.spawnPromisified(
671738
process.execPath,

0 commit comments

Comments
 (0)