Skip to content

Commit c1881e2

Browse files
committed
test_runner: support mocking non-existent modules
1 parent 68d7b6f commit c1881e2

File tree

3 files changed

+197
-28
lines changed

3 files changed

+197
-28
lines changed

lib/internal/test_runner/mock/loader.js

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ const mocks = new SafeMap();
1818
function resolve(specifier, context, nextResolve) {
1919
debug('resolve hook entry, specifier = "%s", context = %o', specifier, context);
2020

21+
// Virtual mocks - skip resolution, the module may not exist on disk.
22+
const virtualMock = mocks.get(specifier);
23+
if (virtualMock?.virtual && virtualMock?.active === true) {
24+
const url = new URL(virtualMock.url);
25+
url.searchParams.set(kMockSearchParam, virtualMock.localVersion);
26+
if (!virtualMock.cache) {
27+
virtualMock.localVersion++;
28+
}
29+
30+
const { href } = url;
31+
debug('resolve hook finished (virtual), url = "%s"', href);
32+
return { __proto__: null, url: href, format: virtualMock.format, shortCircuit: true };
33+
}
34+
2135
const nextResolveResult = nextResolve(specifier, context);
2236
const mockSpecifier = nextResolveResult.url;
2337

@@ -52,25 +66,32 @@ function load(url, context, nextLoad) {
5266
const baseURL = parsedURL ? parsedURL.href : url;
5367
const mock = mocks.get(baseURL);
5468

55-
const original = nextLoad(url, context);
56-
debug('load hook, mock = %o', mock);
5769
if (mock?.active !== true) {
70+
const original = nextLoad(url, context);
71+
debug('load hook, mock = %o', mock);
5872
return original;
5973
}
6074

61-
// Treat builtins as commonjs because customization hooks do not allow a
62-
// core module to be replaced.
63-
// Also collapse 'commonjs-sync' and 'require-commonjs' to 'commonjs'.
64-
let format = original.format;
65-
switch (original.format) {
66-
case 'builtin': // Deliberate fallthrough
67-
case 'commonjs-sync': // Deliberate fallthrough
68-
case 'require-commonjs':
69-
format = 'commonjs';
70-
break;
71-
case 'json':
72-
format = 'module';
73-
break;
75+
let format;
76+
if (mock.virtual) {
77+
// Virtual mock - no real module to load from disk.
78+
format = mock.format;
79+
} else {
80+
const original = nextLoad(url, context);
81+
// Treat builtins as commonjs because customization hooks do not allow a
82+
// core module to be replaced.
83+
// Also collapse 'commonjs-sync' and 'require-commonjs' to 'commonjs'.
84+
format = original.format;
85+
switch (original.format) {
86+
case 'builtin': // Deliberate fallthrough
87+
case 'commonjs-sync': // Deliberate fallthrough
88+
case 'require-commonjs':
89+
format = 'commonjs';
90+
break;
91+
case 'json':
92+
format = 'module';
93+
break;
94+
}
7495
}
7596

7697
const result = {

lib/internal/test_runner/mock/mock.js

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
SafeMap,
1717
StringPrototypeSlice,
1818
StringPrototypeStartsWith,
19+
encodeURIComponent,
1920
} = primordials;
2021
const {
2122
codes: {
@@ -192,6 +193,7 @@ class MockModuleContext {
192193
namedExports,
193194
sharedState,
194195
specifier,
196+
virtual,
195197
}) {
196198
const config = {
197199
__proto__: null,
@@ -200,19 +202,26 @@ class MockModuleContext {
200202
hasDefaultExport,
201203
namedExports,
202204
caller,
205+
virtual,
203206
};
204207

205208
sharedState.mockMap.set(baseURL, config);
206-
sharedState.mockMap.set(fullPath, config);
209+
if (fullPath) {
210+
sharedState.mockMap.set(fullPath, config);
211+
} else if (virtual) {
212+
// Virtual mock - store under raw specifier for CJS resolution fallback.
213+
sharedState.mockMap.set(specifier, config);
214+
}
207215

208216
this.#sharedState = sharedState;
209217
this.#restore = {
210218
__proto__: null,
211219
baseURL,
212-
cached: fullPath in Module._cache,
220+
cached: fullPath ? fullPath in Module._cache : false,
213221
format,
214222
fullPath,
215-
value: Module._cache[fullPath],
223+
specifier: virtual ? specifier : undefined,
224+
value: fullPath ? Module._cache[fullPath] : undefined,
216225
};
217226

218227
const mock = mocks.get(baseURL);
@@ -226,7 +235,7 @@ class MockModuleContext {
226235
const localVersion = mock?.localVersion ?? 0;
227236

228237
debug('new mock version %d for "%s"', localVersion, baseURL);
229-
mocks.set(baseURL, {
238+
const mockEntry = {
230239
__proto__: null,
231240
url: baseURL,
232241
cache,
@@ -235,10 +244,17 @@ class MockModuleContext {
235244
format,
236245
localVersion,
237246
active: true,
238-
});
247+
virtual,
248+
};
249+
mocks.set(baseURL, mockEntry);
250+
if (virtual) {
251+
mocks.set(specifier, mockEntry);
252+
}
239253
}
240254

241-
delete Module._cache[fullPath];
255+
if (fullPath) {
256+
delete Module._cache[fullPath];
257+
}
242258
sharedState.mockExports.set(baseURL, {
243259
__proto__: null,
244260
defaultExport,
@@ -251,12 +267,19 @@ class MockModuleContext {
251267
return;
252268
}
253269

254-
// Delete the mock CJS cache entry. If the module was previously in the
255-
// cache then restore the old value.
256-
delete Module._cache[this.#restore.fullPath];
270+
if (this.#restore.fullPath) {
271+
// Delete the mock CJS cache entry. If the module was previously in the
272+
// cache then restore the old value.
273+
delete Module._cache[this.#restore.fullPath];
274+
275+
if (this.#restore.cached) {
276+
Module._cache[this.#restore.fullPath] = this.#restore.value;
277+
}
257278

258-
if (this.#restore.cached) {
259-
Module._cache[this.#restore.fullPath] = this.#restore.value;
279+
this.#sharedState.mockMap.delete(this.#restore.fullPath);
280+
} else if (this.#restore.specifier !== undefined) {
281+
// Virtual mock - clean up specifier key.
282+
this.#sharedState.mockMap.delete(this.#restore.specifier);
260283
}
261284

262285
const mock = mocks.get(this.#restore.baseURL);
@@ -267,7 +290,9 @@ class MockModuleContext {
267290
}
268291

269292
this.#sharedState.mockMap.delete(this.#restore.baseURL);
270-
this.#sharedState.mockMap.delete(this.#restore.fullPath);
293+
if (this.#restore.specifier !== undefined) {
294+
mocks.delete(this.#restore.specifier);
295+
}
271296
this.#restore = undefined;
272297
}
273298
}
@@ -630,10 +655,12 @@ class MockTracker {
630655
cache = false,
631656
namedExports = kEmptyObject,
632657
defaultExport,
658+
virtual = false,
633659
} = options;
634660
const hasDefaultExport = 'defaultExport' in options;
635661

636662
validateBoolean(cache, 'options.cache');
663+
validateBoolean(virtual, 'options.virtual');
637664
validateObject(namedExports, 'options.namedExports');
638665

639666
const sharedState = setupSharedModuleState();
@@ -646,6 +673,33 @@ class MockTracker {
646673
// If the caller is already a file URL, use it as is. Otherwise, convert it.
647674
const hasFileProtocol = StringPrototypeStartsWith(filename, 'file://');
648675
const caller = hasFileProtocol ? filename : pathToFileURL(filename).href;
676+
if (virtual) {
677+
const url = `mock:///${encodeURIComponent(mockSpecifier)}`;
678+
const format = 'module';
679+
const baseURL = URLParse(url);
680+
const ctx = new MockModuleContext({
681+
__proto__: null,
682+
baseURL: baseURL.href,
683+
cache,
684+
caller,
685+
defaultExport,
686+
format,
687+
fullPath: null,
688+
hasDefaultExport,
689+
namedExports,
690+
sharedState,
691+
specifier: mockSpecifier,
692+
virtual,
693+
});
694+
695+
ArrayPrototypePush(this.#mocks, {
696+
__proto__: null,
697+
ctx,
698+
restore: restoreModule,
699+
});
700+
return ctx;
701+
}
702+
649703
const request = { __proto__: null, specifier: mockSpecifier, attributes: kEmptyObject };
650704
const { format, url } = sharedState.moduleLoader.resolveSync(caller, request);
651705
debug('module mock, url = "%s", format = "%s", caller = "%s"', url, format, caller);
@@ -680,6 +734,7 @@ class MockTracker {
680734
namedExports,
681735
sharedState,
682736
specifier: mockSpecifier,
737+
virtual,
683738
});
684739

685740
ArrayPrototypePush(this.#mocks, {
@@ -841,7 +896,10 @@ function setupSharedModuleState() {
841896
function cjsMockModuleLoad(request, parent, isMain) {
842897
let resolved;
843898

844-
if (isBuiltin(request)) {
899+
// Virtual mock - skip resolution, the module doesn't exist on disk.
900+
if (this.mockMap.get(request)?.virtual) {
901+
resolved = request;
902+
} else if (isBuiltin(request)) {
845903
resolved = ensureNodeScheme(request);
846904
} else {
847905
resolved = _resolveFilename(request, parent, isMain);

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,3 +679,93 @@ test('wrong import syntax should throw error after module mocking', async () =>
679679
assert.match(stderr, /Error \[ERR_MODULE_NOT_FOUND\]: Cannot find module/);
680680
assert.strictEqual(code, 1);
681681
});
682+
683+
test('virtual mock of nonexistent module with ESM', async (t) => {
684+
await t.test('mock with namedExports', async (t) => {
685+
t.mock.module('nonexistent-esm-pkg', {
686+
virtual: true,
687+
namedExports: { hello() { return 'mocked'; } },
688+
});
689+
690+
const mod = await import('nonexistent-esm-pkg');
691+
assert.strictEqual(mod.hello(), 'mocked');
692+
});
693+
694+
await t.test('mock with defaultExport', async (t) => {
695+
const defaultValue = { key: 'value' };
696+
t.mock.module('nonexistent-esm-default', {
697+
virtual: true,
698+
defaultExport: defaultValue,
699+
});
700+
701+
const mod = await import('nonexistent-esm-default');
702+
assert.deepStrictEqual(mod.default, defaultValue);
703+
});
704+
705+
await t.test('mock with both namedExports and defaultExport', async (t) => {
706+
t.mock.module('nonexistent-esm-both', {
707+
virtual: true,
708+
defaultExport: 'the default',
709+
namedExports: { foo: 42 },
710+
});
711+
712+
const mod = await import('nonexistent-esm-both');
713+
assert.strictEqual(mod.default, 'the default');
714+
assert.strictEqual(mod.foo, 42);
715+
});
716+
});
717+
718+
test('virtual mock restore works', async (t) => {
719+
const ctx = t.mock.module('nonexistent-restore-pkg', {
720+
virtual: true,
721+
namedExports: { value: 1 },
722+
});
723+
724+
const mod = await import('nonexistent-restore-pkg');
725+
assert.strictEqual(mod.value, 1);
726+
727+
ctx.restore();
728+
729+
await assert.rejects(
730+
import('nonexistent-restore-pkg'),
731+
{ code: 'ERR_MODULE_NOT_FOUND' },
732+
);
733+
});
734+
735+
test('virtual mock of nonexistent module with CJS', async (t) => {
736+
t.mock.module('nonexistent-cjs-pkg', {
737+
virtual: true,
738+
namedExports: { greet() { return 'hi'; } },
739+
});
740+
741+
const mod = require('nonexistent-cjs-pkg');
742+
assert.strictEqual(mod.greet(), 'hi');
743+
});
744+
745+
test('nonexistent module without virtual flag still throws', async (t) => {
746+
assert.throws(() => {
747+
t.mock.module('totally-nonexistent-pkg-12345', {
748+
namedExports: { foo: 'bar' },
749+
});
750+
}, { code: 'ERR_MODULE_NOT_FOUND' });
751+
});
752+
753+
test('virtual mock overrides an existing module', async (t) => {
754+
const original = require('readline');
755+
assert.strictEqual(typeof original.cursorTo, 'function');
756+
757+
t.mock.module('readline', {
758+
virtual: true,
759+
namedExports: { custom() { return 'virtual'; } },
760+
});
761+
762+
const mocked = await import('readline');
763+
assert.strictEqual(mocked.custom(), 'virtual');
764+
assert.strictEqual(mocked.cursorTo, undefined);
765+
});
766+
767+
test('input validation for virtual option', async (t) => {
768+
assert.throws(() => {
769+
t.mock.module('some-pkg', { virtual: 'yes' });
770+
}, { code: 'ERR_INVALID_ARG_TYPE' });
771+
});

0 commit comments

Comments
 (0)