Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions lib/internal/test_runner/mock/loader.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
'use strict';
const {
ArrayPrototypePop,
ArrayPrototypePush,
JSONStringify,
SafeMap,
SafeSet,
} = primordials;

const kMockSearchParam = 'node-test-mock';
Expand All @@ -14,13 +17,50 @@ 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);

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);

Expand Down Expand Up @@ -144,6 +184,7 @@ if (module.exports === null || typeof module.exports !== 'object') {
}

module.exports = {
collectMockDependents,
hooks: { __proto__: null, load, resolve },
mocks,
constants: {
Expand Down
10 changes: 10 additions & 0 deletions lib/internal/test_runner/mock/mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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() {
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions test/fixtures/module-mocking/reset-consumer-b.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import dependency from './reset-dependency.mjs';

export default function consumerB() {
return dependency();
}
5 changes: 5 additions & 0 deletions test/fixtures/module-mocking/reset-consumer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import dependency from './reset-dependency.mjs';

export default function consumer() {
return dependency();
}
3 changes: 3 additions & 0 deletions test/fixtures/module-mocking/reset-dependency.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function dependency() {
return 'original dependency';
}
5 changes: 5 additions & 0 deletions test/fixtures/module-mocking/reset-named-consumer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { readValue } from './reset-named-dependency.mjs';

export function run() {
return readValue();
}
3 changes: 3 additions & 0 deletions test/fixtures/module-mocking/reset-named-dependency.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function readValue() {
return 'original value';
}
5 changes: 5 additions & 0 deletions test/fixtures/module-mocking/reset-nested-consumer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import consumer from './reset-consumer.mjs';

export default function nestedConsumer() {
return consumer();
}
121 changes: 120 additions & 1 deletion test/parallel/test-runner-module-mocking.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);

Expand Down
Loading