Skip to content

Commit 2c63d30

Browse files
Han5991aduh95
authored andcommitted
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 PR-URL: #61727 Reviewed-By: James M Snell <[email protected]> Reviewed-By: Jacob Smith <[email protected]> Reviewed-By: Pietro Marchini <[email protected]> Reviewed-By: Gürgün Dayıoğlu <[email protected]> Reviewed-By: Chemi Atlow <[email protected]>
1 parent e992a34 commit 2c63d30

File tree

8 files changed

+256
-60
lines changed

8 files changed

+256
-60
lines changed

doc/api/test.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2480,16 +2480,32 @@ changes:
24802480
generates a new mock module. If `true`, subsequent calls will return the same
24812481
module mock, and the mock module is inserted into the CommonJS cache.
24822482
**Default:** false.
2483+
* `exports` {Object} Optional mocked exports. The `default` property, if
2484+
provided, is used as the mocked module's default export. All other own
2485+
enumerable properties are used as named exports.
2486+
**This option cannot be used with `defaultExport` or `namedExports`.**
2487+
* If the mock is a CommonJS or builtin module, `exports.default` is used as
2488+
the value of `module.exports`.
2489+
* If `exports.default` is not provided for a CommonJS or builtin mock,
2490+
`module.exports` defaults to an empty object.
2491+
* If named exports are provided with a non-object default export, the mock
2492+
throws an exception when used as a CommonJS or builtin module.
24832493
* `defaultExport` {any} An optional value used as the mocked module's default
24842494
export. If this value is not provided, ESM mocks do not include a default
24852495
export. If the mock is a CommonJS or builtin module, this setting is used as
24862496
the value of `module.exports`. If this value is not provided, CJS and builtin
24872497
mocks use an empty object as the value of `module.exports`.
2498+
**This option cannot be used with `options.exports`.**
2499+
This option is deprecated and will be removed in a later version.
2500+
Prefer `options.exports.default`.
24882501
* `namedExports` {Object} An optional object whose keys and values are used to
24892502
create the named exports of the mock module. If the mock is a CommonJS or
24902503
builtin module, these values are copied onto `module.exports`. Therefore, if a
24912504
mock is created with both named exports and a non-object default export, the
24922505
mock will throw an exception when used as a CJS or builtin module.
2506+
**This option cannot be used with `options.exports`.**
2507+
This option is deprecated and will be removed in a later version.
2508+
Prefer `options.exports`.
24932509
* Returns: {MockModuleContext} An object that can be used to manipulate the mock.
24942510

24952511
This function is used to mock the exports of ECMAScript modules, CommonJS modules, JSON modules, and
@@ -2506,10 +2522,10 @@ The following example demonstrates how a mock is created for a module.
25062522

25072523
```js
25082524
test('mocks a builtin module in both module systems', async (t) => {
2509-
// Create a mock of 'node:readline' with a named export named 'fn', which
2525+
// Create a mock of 'node:readline' with a named export named 'foo', which
25102526
// does not exist in the original 'node:readline' module.
25112527
const mock = t.mock.module('node:readline', {
2512-
namedExports: { fn() { return 42; } },
2528+
exports: { foo: () => 42 },
25132529
});
25142530

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

lib/internal/test_runner/mock/loader.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,10 @@ function defaultExportSource(useESM, hasDefaultExport) {
113113
if (!hasDefaultExport) {
114114
return '';
115115
} else if (useESM) {
116-
return 'export default $__exports.defaultExport;';
116+
return 'export default $__exports.moduleExports.default;';
117117
}
118118

119-
return 'module.exports = $__exports.defaultExport;';
119+
return 'module.exports = $__exports.moduleExports.default;';
120120
}
121121

122122
function namedExportsSource(useESM, exportNames) {
@@ -134,9 +134,9 @@ if (module.exports === null || typeof module.exports !== 'object') {
134134
const name = exportNames[i];
135135

136136
if (useESM) {
137-
source += `export let ${name} = $__exports.namedExports[${JSONStringify(name)}];\n`;
137+
source += `export let ${name} = $__exports.moduleExports[${JSONStringify(name)}];\n`;
138138
} else {
139-
source += `module.exports[${JSONStringify(name)}] = $__exports.namedExports[${JSONStringify(name)}];\n`;
139+
source += `module.exports[${JSONStringify(name)}] = $__exports.moduleExports[${JSONStringify(name)}];\n`;
140140
}
141141
}
142142

lib/internal/test_runner/mock/mock.js

Lines changed: 92 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
'use strict';
22
const {
3+
ArrayPrototypeFilter,
34
ArrayPrototypePush,
45
ArrayPrototypeSlice,
56
Error,
67
FunctionPrototypeBind,
78
FunctionPrototypeCall,
9+
ObjectAssign,
810
ObjectDefineProperty,
911
ObjectGetOwnPropertyDescriptor,
1012
ObjectGetPrototypeOf,
@@ -33,6 +35,7 @@ const {
3335
URLParse,
3436
} = require('internal/url');
3537
const {
38+
deprecateProperty,
3639
emitExperimentalWarning,
3740
getStructuredStack,
3841
kEmptyObject,
@@ -61,6 +64,14 @@ const kSupportedFormats = [
6164
'module',
6265
];
6366
let sharedModuleState;
67+
const deprecateNamedExports = deprecateProperty(
68+
'namedExports',
69+
'mock.module(): options.namedExports is deprecated. Use options.exports instead.',
70+
);
71+
const deprecateDefaultExport = deprecateProperty(
72+
'defaultExport',
73+
'mock.module(): options.defaultExport is deprecated. Use options.exports.default instead.',
74+
);
6475
const {
6576
hooks: mockHooks,
6677
mocks,
@@ -185,20 +196,16 @@ class MockModuleContext {
185196
baseURL,
186197
cache,
187198
caller,
188-
defaultExport,
189199
format,
190200
fullPath,
191-
hasDefaultExport,
192-
namedExports,
201+
moduleExports,
193202
sharedState,
194203
specifier,
195204
}) {
196205
const config = {
197206
__proto__: null,
198207
cache,
199-
defaultExport,
200-
hasDefaultExport,
201-
namedExports,
208+
moduleExports,
202209
caller,
203210
};
204211

@@ -230,8 +237,8 @@ class MockModuleContext {
230237
__proto__: null,
231238
url: baseURL,
232239
cache,
233-
exportNames: ObjectKeys(namedExports),
234-
hasDefaultExport,
240+
exportNames: ArrayPrototypeFilter(ObjectKeys(moduleExports), (k) => k !== 'default'),
241+
hasDefaultExport: 'default' in moduleExports,
235242
format,
236243
localVersion,
237244
active: true,
@@ -241,8 +248,7 @@ class MockModuleContext {
241248
delete Module._cache[fullPath];
242249
sharedState.mockExports.set(baseURL, {
243250
__proto__: null,
244-
defaultExport,
245-
namedExports,
251+
moduleExports,
246252
});
247253
}
248254

@@ -627,14 +633,9 @@ class MockTracker {
627633
debug('module mock entry, specifier = "%s", options = %o', specifier, options);
628634

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

639640
const sharedState = setupSharedModuleState();
640641
const mockSpecifier = StringPrototypeStartsWith(specifier, 'node:') ?
@@ -673,11 +674,9 @@ class MockTracker {
673674
baseURL: baseURL.href,
674675
cache,
675676
caller,
676-
defaultExport,
677677
format,
678678
fullPath,
679-
hasDefaultExport,
680-
namedExports,
679+
moduleExports,
681680
sharedState,
682681
specifier: mockSpecifier,
683682
});
@@ -816,6 +815,73 @@ class MockTracker {
816815
}
817816
}
818817

818+
function normalizeModuleMockOptions(options) {
819+
const { cache = false } = options;
820+
validateBoolean(cache, 'options.cache');
821+
822+
const hasExports = 'exports' in options;
823+
const hasNamedExports = 'namedExports' in options;
824+
const hasDefaultExport = 'defaultExport' in options;
825+
826+
deprecateNamedExports(options);
827+
deprecateDefaultExport(options);
828+
829+
const moduleExports = { __proto__: null };
830+
831+
if (hasExports) {
832+
validateObject(options.exports, 'options.exports');
833+
}
834+
835+
if (hasNamedExports) {
836+
validateObject(options.namedExports, 'options.namedExports');
837+
}
838+
839+
if (hasExports && (hasNamedExports || hasDefaultExport)) {
840+
let reason = "cannot be used with 'options.namedExports'";
841+
842+
if (hasDefaultExport) {
843+
reason = hasNamedExports ?
844+
"cannot be used with 'options.namedExports' or 'options.defaultExport'" :
845+
"cannot be used with 'options.defaultExport'";
846+
}
847+
848+
throw new ERR_INVALID_ARG_VALUE('options.exports', options.exports, reason);
849+
}
850+
851+
if (hasExports) {
852+
copyOwnProperties(options.exports, moduleExports);
853+
}
854+
855+
if (hasNamedExports) {
856+
copyOwnProperties(options.namedExports, moduleExports);
857+
}
858+
859+
if (hasDefaultExport) {
860+
ObjectDefineProperty(
861+
moduleExports,
862+
'default',
863+
ObjectAssign({ __proto__: null }, ObjectGetOwnPropertyDescriptor(options, 'defaultExport')),
864+
);
865+
}
866+
867+
return {
868+
__proto__: null,
869+
cache,
870+
moduleExports,
871+
};
872+
}
873+
874+
875+
function copyOwnProperties(from, to) {
876+
const keys = ObjectKeys(from);
877+
878+
for (let i = 0; i < keys.length; ++i) {
879+
const key = keys[i];
880+
const descriptor = ObjectGetOwnPropertyDescriptor(from, key);
881+
ObjectDefineProperty(to, key, descriptor);
882+
}
883+
}
884+
819885
function setupSharedModuleState() {
820886
if (sharedModuleState === undefined) {
821887
const { mock } = require('test');
@@ -855,9 +921,7 @@ function cjsMockModuleLoad(request, parent, isMain) {
855921
const {
856922
cache,
857923
caller,
858-
defaultExport,
859-
hasDefaultExport,
860-
namedExports,
924+
moduleExports,
861925
} = config;
862926

863927
if (cache && Module._cache[resolved]) {
@@ -866,9 +930,10 @@ function cjsMockModuleLoad(request, parent, isMain) {
866930
return Module._cache[resolved].exports;
867931
}
868932

933+
const hasDefaultExport = 'default' in moduleExports;
869934
// eslint-disable-next-line node-core/set-proto-to-null-in-object
870-
const modExports = hasDefaultExport ? defaultExport : {};
871-
const exportNames = ObjectKeys(namedExports);
935+
const modExports = hasDefaultExport ? moduleExports.default : {};
936+
const exportNames = ArrayPrototypeFilter(ObjectKeys(moduleExports), (k) => k !== 'default');
872937

873938
if ((typeof modExports !== 'object' || modExports === null) &&
874939
exportNames.length > 0) {
@@ -878,7 +943,7 @@ function cjsMockModuleLoad(request, parent, isMain) {
878943

879944
for (let i = 0; i < exportNames.length; ++i) {
880945
const name = exportNames[i];
881-
const descriptor = ObjectGetOwnPropertyDescriptor(namedExports, name);
946+
const descriptor = ObjectGetOwnPropertyDescriptor(moduleExports, name);
882947
ObjectDefineProperty(modExports, name, descriptor);
883948
}
884949

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

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

33
const dependency = mock.fn(() => 'mock-return-value');
4-
mock.module('../coverage-with-mock/dependency.cjs', { namedExports: { dependency } });
4+
mock.module('../coverage-with-mock/dependency.cjs', { exports: { dependency } });
55

66
const { subject } = await import('../coverage-with-mock/subject.mjs');
77

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

0 commit comments

Comments
 (0)