Skip to content

Commit 0498de3

Browse files
committed
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: #58443
1 parent 0777dcc commit 0498de3

File tree

6 files changed

+199
-34
lines changed

6 files changed

+199
-34
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: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ const kSupportedFormats = [
6161
'module',
6262
];
6363
let sharedModuleState;
64+
let warnedLegacyDefaultExport;
65+
let warnedLegacyNamedExports;
6466
const {
6567
hooks: mockHooks,
6668
mocks,
@@ -627,14 +629,11 @@ class MockTracker {
627629
debug('module mock entry, specifier = "%s", options = %o', specifier, options);
628630

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

639638
const sharedState = setupSharedModuleState();
640639
const mockSpecifier = StringPrototypeStartsWith(specifier, 'node:') ?
@@ -816,6 +815,90 @@ class MockTracker {
816815
}
817816
}
818817

818+
function normalizeModuleMockOptions(options) {
819+
const { cache = false } = options;
820+
validateBoolean(cache, 'options.cache');
821+
822+
const moduleExports = {
823+
__proto__: null,
824+
};
825+
826+
if ('exports' in options) {
827+
validateObject(options.exports, 'options.exports');
828+
copyOwnProperties(options.exports, moduleExports);
829+
}
830+
831+
if ('namedExports' in options) {
832+
validateObject(options.namedExports, 'options.namedExports');
833+
emitLegacyMockOptionWarning('namedExports');
834+
copyOwnProperties(options.namedExports, moduleExports);
835+
}
836+
837+
if ('defaultExport' in options) {
838+
emitLegacyMockOptionWarning('defaultExport');
839+
moduleExports.default = options.defaultExport;
840+
}
841+
842+
const namedExports = { __proto__: null };
843+
const exportNames = ObjectKeys(moduleExports);
844+
845+
for (let i = 0; i < exportNames.length; ++i) {
846+
const name = exportNames[i];
847+
848+
if (name === 'default') {
849+
continue;
850+
}
851+
852+
const descriptor = ObjectGetOwnPropertyDescriptor(moduleExports, name);
853+
ObjectDefineProperty(namedExports, name, descriptor);
854+
}
855+
856+
return {
857+
__proto__: null,
858+
cache,
859+
defaultExport: moduleExports.default,
860+
hasDefaultExport: 'default' in moduleExports,
861+
namedExports,
862+
};
863+
}
864+
865+
function emitLegacyMockOptionWarning(option) {
866+
switch (option) {
867+
case 'defaultExport':
868+
if (warnedLegacyDefaultExport === true) {
869+
return;
870+
}
871+
warnedLegacyDefaultExport = true;
872+
process.emitWarning(
873+
'mock.module(): options.defaultExport is deprecated. ' +
874+
'Use options.exports.default instead.',
875+
'DeprecationWarning',
876+
);
877+
break;
878+
case 'namedExports':
879+
if (warnedLegacyNamedExports === true) {
880+
return;
881+
}
882+
warnedLegacyNamedExports = true;
883+
process.emitWarning(
884+
'mock.module(): options.namedExports is deprecated. ' +
885+
'Use options.exports instead.',
886+
'DeprecationWarning',
887+
);
888+
break;
889+
}
890+
}
891+
892+
function copyOwnProperties(from, to) {
893+
const keys = ObjectKeys(from);
894+
895+
for (let i = 0; i < keys.length; ++i) {
896+
const key = keys[i];
897+
const descriptor = ObjectGetOwnPropertyDescriptor(from, key);
898+
ObjectDefineProperty(to, key, descriptor);
899+
}
900+
}
901+
819902
function setupSharedModuleState() {
820903
if (sharedModuleState === undefined) {
821904
const { mock } = require('test');

test/fixtures/test-runner/output/coverage-with-mock.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, mock } from 'node:test';
22

33
describe('module test with mock', async () => {
44
mock.module('../coverage-with-mock/sum.js', {
5-
namedExports: {
5+
exports: {
66
sum: (a, b) => 1,
77
getData: () => ({}),
88
},

test/fixtures/test-runner/output/typescript-coverage.mts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ describe('foo', { concurrency: true }, () => {
1010
.then(({ default: _, ...rest }) => rest);
1111

1212
mock.module('../coverage/bar.mts', {
13-
defaultExport: barMock,
14-
namedExports: barNamedExports,
13+
exports: {
14+
...barNamedExports,
15+
default: barMock,
16+
},
1517
});
1618

1719
({ foo } = await import('../coverage/foo.mts'));

test/fixtures/test-runner/output/typescript-coverage.snapshot

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ ok 1 - foo
3434
# output | | | |
3535
# typescript-coverage.mts | 100.00 | 100.00 | 100.00 |
3636
# ----------------------------------------------------------------------------
37-
# all files | 93.55 | 100.00 | 85.71 |
37+
# all files | 93.94 | 100.00 | 85.71 |
3838
# ----------------------------------------------------------------------------
3939
# end of coverage report

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)