diff --git a/lib/internal/test_runner/mock/loader.js b/lib/internal/test_runner/mock/loader.js index d64ffdfa096559..4deedd695f06c3 100644 --- a/lib/internal/test_runner/mock/loader.js +++ b/lib/internal/test_runner/mock/loader.js @@ -1,7 +1,10 @@ 'use strict'; const { + ArrayPrototypePop, + ArrayPrototypePush, JSONStringify, SafeMap, + SafeSet, } = primordials; const kMockSearchParam = 'node-test-mock'; @@ -14,6 +17,34 @@ let debug = require('internal/util/debuglog').debuglog('test_runner', (fn) => { }); const mocks = new SafeMap(); +const moduleImporters = new SafeMap(); + +/** + * @param {string} url Resolved URL of the module to start from. + * @returns {SafeSet} The set of importer URLs. + */ +function collectMockDependents(url) { + const dependents = new SafeSet(); + const queue = [url]; + + while (queue.length > 0) { + const current = ArrayPrototypePop(queue); + const importers = moduleImporters.get(current); + + if (importers === undefined) { + continue; + } + + for (const importer of importers) { + if (!dependents.has(importer)) { + dependents.add(importer); + ArrayPrototypePush(queue, importer); + } + } + } + + return dependents; +} function resolve(specifier, context, nextResolve) { debug('resolve hook entry, specifier = "%s", context = %o', specifier, context); @@ -21,6 +52,15 @@ function resolve(specifier, context, nextResolve) { const nextResolveResult = nextResolve(specifier, context); const mockSpecifier = nextResolveResult.url; + if (context.parentURL !== undefined) { + const importers = moduleImporters.get(mockSpecifier); + if (importers === undefined) { + moduleImporters.set(mockSpecifier, new SafeSet([context.parentURL])); + } else { + importers.add(context.parentURL); + } + } + const mock = mocks.get(mockSpecifier); debug('resolve hook, specifier = "%s", mock = %o', specifier, mock); @@ -144,6 +184,7 @@ if (module.exports === null || typeof module.exports !== 'object') { } module.exports = { + collectMockDependents, hooks: { __proto__: null, load, resolve }, mocks, constants: { diff --git a/lib/internal/test_runner/mock/mock.js b/lib/internal/test_runner/mock/mock.js index 970356bcae3aa0..c0c1c7652ccfc4 100644 --- a/lib/internal/test_runner/mock/mock.js +++ b/lib/internal/test_runner/mock/mock.js @@ -84,6 +84,7 @@ const deprecateDefaultExport = deprecateProperty( 'mock.module(): options.defaultExport is deprecated. Use options.exports.default instead.', ); const { + collectMockDependents, hooks: mockHooks, mocks, constants: { kBadExportsMessage, kMockSearchParam }, @@ -261,6 +262,14 @@ class MockModuleContext { __proto__: null, moduleExports, }); + this.#evictDependents(baseURL); + } + + #evictDependents(baseURL) { + const { loadCache } = this.#sharedState.moduleLoader; + for (const dependent of collectMockDependents(baseURL)) { + loadCache.delete(dependent); + } } restore() { @@ -282,6 +291,7 @@ class MockModuleContext { mock.active = false; mock.localVersion++; } + this.#evictDependents(this.#restore.baseURL); this.#sharedState.mockMap.delete(this.#restore.baseURL); this.#sharedState.mockMap.delete(this.#restore.fullPath); diff --git a/test/fixtures/module-mocking/reset-consumer-b.mjs b/test/fixtures/module-mocking/reset-consumer-b.mjs new file mode 100644 index 00000000000000..07d21e38d60259 --- /dev/null +++ b/test/fixtures/module-mocking/reset-consumer-b.mjs @@ -0,0 +1,5 @@ +import dependency from './reset-dependency.mjs'; + +export default function consumerB() { + return dependency(); +} diff --git a/test/fixtures/module-mocking/reset-consumer.mjs b/test/fixtures/module-mocking/reset-consumer.mjs new file mode 100644 index 00000000000000..8ce363b8f5a2fd --- /dev/null +++ b/test/fixtures/module-mocking/reset-consumer.mjs @@ -0,0 +1,5 @@ +import dependency from './reset-dependency.mjs'; + +export default function consumer() { + return dependency(); +} diff --git a/test/fixtures/module-mocking/reset-dependency.mjs b/test/fixtures/module-mocking/reset-dependency.mjs new file mode 100644 index 00000000000000..fcea2bb8fa9085 --- /dev/null +++ b/test/fixtures/module-mocking/reset-dependency.mjs @@ -0,0 +1,3 @@ +export default function dependency() { + return 'original dependency'; +} diff --git a/test/fixtures/module-mocking/reset-named-consumer.mjs b/test/fixtures/module-mocking/reset-named-consumer.mjs new file mode 100644 index 00000000000000..bdc3be36730dab --- /dev/null +++ b/test/fixtures/module-mocking/reset-named-consumer.mjs @@ -0,0 +1,5 @@ +import { readValue } from './reset-named-dependency.mjs'; + +export function run() { + return readValue(); +} diff --git a/test/fixtures/module-mocking/reset-named-dependency.mjs b/test/fixtures/module-mocking/reset-named-dependency.mjs new file mode 100644 index 00000000000000..06cf2a10f97dca --- /dev/null +++ b/test/fixtures/module-mocking/reset-named-dependency.mjs @@ -0,0 +1,3 @@ +export function readValue() { + return 'original value'; +} diff --git a/test/fixtures/module-mocking/reset-nested-consumer.mjs b/test/fixtures/module-mocking/reset-nested-consumer.mjs new file mode 100644 index 00000000000000..e3d1b2bfc691af --- /dev/null +++ b/test/fixtures/module-mocking/reset-nested-consumer.mjs @@ -0,0 +1,5 @@ +import consumer from './reset-consumer.mjs'; + +export default function nestedConsumer() { + return consumer(); +} diff --git a/test/parallel/test-runner-module-mocking.js b/test/parallel/test-runner-module-mocking.js index 7f2c76663ba29c..67d6388be4a382 100644 --- a/test/parallel/test-runner-module-mocking.js +++ b/test/parallel/test-runner-module-mocking.js @@ -10,7 +10,7 @@ if (!isMainThread) { const fixtures = require('../common/fixtures'); const assert = require('node:assert'); const { relative } = require('node:path'); -const { test } = require('node:test'); +const { beforeEach, describe, mock, test } = require('node:test'); const { pathToFileURL } = require('node:url'); test('input validation', async (t) => { @@ -553,6 +553,125 @@ test('mocks can be restored independently', async (t) => { assert.strictEqual(esmImpl.fn, undefined); }); +// Refs https://github.com/nodejs/node/issues/59163 +test('re-mocking a dependency resets consumers imported in earlier tests', async (t) => { + const dependency = fixtures.fileURL('module-mocking', 'reset-dependency.mjs'); + const consumer = fixtures.fileURL('module-mocking', 'reset-consumer.mjs'); + const nestedConsumer = fixtures.fileURL('module-mocking', 'reset-nested-consumer.mjs'); + + await t.test('direct consumer uses the first mock', async (t) => { + const mockDependency = t.mock.fn(() => 'first mock'); + t.mock.module(dependency, { defaultExport: mockDependency }); + + const { default: consume } = await import(consumer); + + assert.strictEqual(consume(), 'first mock'); + assert.strictEqual(mockDependency.mock.callCount(), 1); + }); + + await t.test('direct consumer uses the second mock', async (t) => { + const mockDependency = t.mock.fn(() => 'second mock'); + t.mock.module(dependency, { defaultExport: mockDependency }); + + const { default: consume } = await import(consumer); + + assert.strictEqual(consume(), 'second mock'); + assert.strictEqual(mockDependency.mock.callCount(), 1); + }); + + await t.test('transitive consumer uses a fresh mock', async (t) => { + const mockDependency = t.mock.fn(() => 'third mock'); + t.mock.module(dependency, { defaultExport: mockDependency }); + + const { default: consume } = await import(nestedConsumer); + + assert.strictEqual(consume(), 'third mock'); + assert.strictEqual(mockDependency.mock.callCount(), 1); + }); + + // After all mocks are restored, the consumers see the original dependency. + assert.strictEqual((await import(consumer)).default(), 'original dependency'); + assert.strictEqual((await import(nestedConsumer)).default(), 'original dependency'); +}); + +test('re-mocking a dependency refreshes every importer', async (t) => { + const dependency = fixtures.fileURL('module-mocking', 'reset-dependency.mjs'); + const consumerA = fixtures.fileURL('module-mocking', 'reset-consumer.mjs'); + const consumerB = fixtures.fileURL('module-mocking', 'reset-consumer-b.mjs'); + + await t.test('first mock reaches both importers', async (t) => { + t.mock.module(dependency, { exports: { default: () => 'first' } }); + + assert.strictEqual((await import(consumerA)).default(), 'first'); + assert.strictEqual((await import(consumerB)).default(), 'first'); + }); + + await t.test('second mock reaches both importers', async (t) => { + t.mock.module(dependency, { exports: { default: () => 'second' } }); + + assert.strictEqual((await import(consumerA)).default(), 'second'); + assert.strictEqual((await import(consumerB)).default(), 'second'); + }); +}); + +test('restoring a mock re-exposes the original module', async (t) => { + const dependency = fixtures.fileURL('module-mocking', 'reset-dependency.mjs'); + const consumer = fixtures.fileURL('module-mocking', 'reset-consumer.mjs'); + + const mocked = t.mock.module(dependency, { exports: { default: () => 'mocked' } }); + assert.strictEqual((await import(consumer)).default(), 'mocked'); + + mocked.restore(); + assert.strictEqual((await import(consumer)).default(), 'original dependency'); +}); + +// Refs https://github.com/nodejs/node/issues/59163 +test('mocking a dependency refreshes consumers imported without a mock', async (t) => { + const dependency = fixtures.fileURL('module-mocking', 'reset-dependency.mjs'); + const consumer = fixtures.fileURL('module-mocking', 'reset-consumer.mjs'); + + t.mock.module(dependency, { exports: { default: () => 'warm up' } }); + t.mock.reset(); + + await t.test('reads the original when unmocked', async (t) => { + assert.strictEqual((await import(consumer)).default(), 'original dependency'); + }); + + await t.test('reads the mock installed afterwards', async (t) => { + t.mock.module(dependency, { exports: { default: () => 'installed later' } }); + assert.strictEqual((await import(consumer)).default(), 'installed later'); + }); +}); + +describe('re-mocking named exports between tests', () => { + const dependency = fixtures.fileURL('module-mocking', 'reset-named-dependency.mjs'); + const consumer = fixtures.fileURL('module-mocking', 'reset-named-consumer.mjs'); + + beforeEach(() => { + mock.restoreAll(); + }); + + test('first test observes its own mock', async (t) => { + const readValue = t.mock.fn(() => 'first'); + t.mock.module(dependency, { exports: { readValue } }); + + const { run } = await import(consumer); + + assert.strictEqual(run(), 'first'); + assert.strictEqual(readValue.mock.callCount(), 1); + }); + + test('second test observes its own mock', async (t) => { + const readValue = t.mock.fn(() => 'second'); + t.mock.module(dependency, { exports: { readValue } }); + + const { run } = await import(consumer); + + assert.strictEqual(run(), 'second'); + assert.strictEqual(readValue.mock.callCount(), 1); + }); +}); + async function assertCoreModuleMockWorksInBothModuleSystems(t, specifier, options) { const coreMock = t.mock.module(specifier, options);