Skip to content

Commit 5d7b662

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

File tree

3 files changed

+196
-28
lines changed

3 files changed

+196
-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: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ class MockModuleContext {
192192
namedExports,
193193
sharedState,
194194
specifier,
195+
virtual,
195196
}) {
196197
const config = {
197198
__proto__: null,
@@ -200,19 +201,26 @@ class MockModuleContext {
200201
hasDefaultExport,
201202
namedExports,
202203
caller,
204+
virtual,
203205
};
204206

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

208215
this.#sharedState = sharedState;
209216
this.#restore = {
210217
__proto__: null,
211218
baseURL,
212-
cached: fullPath in Module._cache,
219+
cached: fullPath ? fullPath in Module._cache : false,
213220
format,
214221
fullPath,
215-
value: Module._cache[fullPath],
222+
specifier: virtual ? specifier : undefined,
223+
value: fullPath ? Module._cache[fullPath] : undefined,
216224
};
217225

218226
const mock = mocks.get(baseURL);
@@ -226,7 +234,7 @@ class MockModuleContext {
226234
const localVersion = mock?.localVersion ?? 0;
227235

228236
debug('new mock version %d for "%s"', localVersion, baseURL);
229-
mocks.set(baseURL, {
237+
const mockEntry = {
230238
__proto__: null,
231239
url: baseURL,
232240
cache,
@@ -235,10 +243,17 @@ class MockModuleContext {
235243
format,
236244
localVersion,
237245
active: true,
238-
});
246+
virtual,
247+
};
248+
mocks.set(baseURL, mockEntry);
249+
if (virtual) {
250+
mocks.set(specifier, mockEntry);
251+
}
239252
}
240253

241-
delete Module._cache[fullPath];
254+
if (fullPath) {
255+
delete Module._cache[fullPath];
256+
}
242257
sharedState.mockExports.set(baseURL, {
243258
__proto__: null,
244259
defaultExport,
@@ -251,12 +266,19 @@ class MockModuleContext {
251266
return;
252267
}
253268

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];
269+
if (this.#restore.fullPath) {
270+
// Delete the mock CJS cache entry. If the module was previously in the
271+
// cache then restore the old value.
272+
delete Module._cache[this.#restore.fullPath];
273+
274+
if (this.#restore.cached) {
275+
Module._cache[this.#restore.fullPath] = this.#restore.value;
276+
}
257277

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

262284
const mock = mocks.get(this.#restore.baseURL);
@@ -267,7 +289,9 @@ class MockModuleContext {
267289
}
268290

269291
this.#sharedState.mockMap.delete(this.#restore.baseURL);
270-
this.#sharedState.mockMap.delete(this.#restore.fullPath);
292+
if (this.#restore.specifier !== undefined) {
293+
mocks.delete(this.#restore.specifier);
294+
}
271295
this.#restore = undefined;
272296
}
273297
}
@@ -630,10 +654,12 @@ class MockTracker {
630654
cache = false,
631655
namedExports = kEmptyObject,
632656
defaultExport,
657+
virtual = false,
633658
} = options;
634659
const hasDefaultExport = 'defaultExport' in options;
635660

636661
validateBoolean(cache, 'options.cache');
662+
validateBoolean(virtual, 'options.virtual');
637663
validateObject(namedExports, 'options.namedExports');
638664

639665
const sharedState = setupSharedModuleState();
@@ -646,6 +672,33 @@ class MockTracker {
646672
// If the caller is already a file URL, use it as is. Otherwise, convert it.
647673
const hasFileProtocol = StringPrototypeStartsWith(filename, 'file://');
648674
const caller = hasFileProtocol ? filename : pathToFileURL(filename).href;
675+
if (virtual) {
676+
const url = `mock:///${encodeURIComponent(mockSpecifier)}`;
677+
const format = 'module';
678+
const baseURL = URLParse(url);
679+
const ctx = new MockModuleContext({
680+
__proto__: null,
681+
baseURL: baseURL.href,
682+
cache,
683+
caller,
684+
defaultExport,
685+
format,
686+
fullPath: null,
687+
hasDefaultExport,
688+
namedExports,
689+
sharedState,
690+
specifier: mockSpecifier,
691+
virtual,
692+
});
693+
694+
ArrayPrototypePush(this.#mocks, {
695+
__proto__: null,
696+
ctx,
697+
restore: restoreModule,
698+
});
699+
return ctx;
700+
}
701+
649702
const request = { __proto__: null, specifier: mockSpecifier, attributes: kEmptyObject };
650703
const { format, url } = sharedState.moduleLoader.resolveSync(caller, request);
651704
debug('module mock, url = "%s", format = "%s", caller = "%s"', url, format, caller);
@@ -680,6 +733,7 @@ class MockTracker {
680733
namedExports,
681734
sharedState,
682735
specifier: mockSpecifier,
736+
virtual,
683737
});
684738

685739
ArrayPrototypePush(this.#mocks, {
@@ -841,7 +895,10 @@ function setupSharedModuleState() {
841895
function cjsMockModuleLoad(request, parent, isMain) {
842896
let resolved;
843897

844-
if (isBuiltin(request)) {
898+
// Virtual mock — skip resolution, the module doesn't exist on disk.
899+
if (this.mockMap.get(request)?.virtual) {
900+
resolved = request;
901+
} else if (isBuiltin(request)) {
845902
resolved = ensureNodeScheme(request);
846903
} else {
847904
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)