Skip to content

Commit 0623958

Browse files
committed
fix!: only mock direct dependencies
This fixes a significant design flaw where mocks would be applied to any module that required a mocked dependency, even if it was a transitive dependency. This meant that if module A mocked module B, and module B required module C, module C would also be affected by the mocks. The original design intention was to only mock direct dependencies of the module being mocked. This is now fixed to match that intention, preventing unintended side effects and making the mocking behavior more predictable and maintainable. BREAKING CHANGE: Mocks will no longer be applied to transitive dependencies. If you were relying on this behavior (e.g., mocking dependencies through re-exports in index.ts files), you will need to update your tests to mock the dependencies directly in the module that requires them.
1 parent 1c83ac3 commit 0623958

4 files changed

Lines changed: 69 additions & 72 deletions

File tree

package-lock.json

Lines changed: 13 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
"rewire"
3636
],
3737
"dependencies": {
38-
"callsites": "3.1.0"
38+
"callsites": "3.1.0",
39+
"colorette": "2.0.20"
3940
},
4041
"devDependencies": {
4142
"@types/node": "^16.11.6",

src/lib/colors.ts

Lines changed: 0 additions & 29 deletions
This file was deleted.

src/mock.ts

Lines changed: 54 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1+
/* eslint-disable no-underscore-dangle */
2+
/* eslint-disable @typescript-eslint/no-var-requires */
3+
/* eslint-disable @typescript-eslint/no-require-imports */
14
import path from 'path';
25
import callsites from 'callsites';
3-
import {bold, green, grey} from './lib/colors';
4-
// eslint-disable-next-line @typescript-eslint/no-require-imports,@typescript-eslint/no-var-requires
6+
import {bold, green, gray} from 'colorette';
57
const Module = require('module');
6-
const registeredMocks = new Map<string, {modulePath: string, mockReturnValue: any}>();
8+
9+
type MockedDeps = {
10+
dependencyPath: string // the dependency being mocked
11+
mockReturnValue: any // what to return for that dependency
12+
parentPath: string // the module whose requires we're intercepting
13+
};
14+
15+
const depsToMock = new Map<string, MockedDeps>();
716

817
function debug(msg: any) {
918

@@ -19,93 +28,96 @@ Module.prototype.require = new Proxy(Module.prototype.require, {
1928
const [name] = argumentsList;
2029
// eslint-disable-next-line no-underscore-dangle
2130
const absolutePath = Module._resolveFilename(name, thisArg);
22-
const mock = registeredMocks.get(absolutePath);
31+
const mock = depsToMock.get(absolutePath);
2332

24-
if (mock) {
33+
// Only replace if the module is a direct dependency of the caller
34+
if (mock && mock.parentPath === thisArg.filename) {
2535

2636
debug(`require(): ${green('REPLACING WITH MOCK')} ${bold(name)} [${absolutePath}] ${getStackTrace()}`);
27-
registeredMocks.delete(absolutePath);
37+
depsToMock.delete(absolutePath);
2838
return mock.mockReturnValue;
2939

30-
} else {
31-
32-
debug(`require(): ${bold(name)} [${absolutePath}] ${getStackTrace()}`);
33-
3440
}
3541

42+
debug(`require(): ${bold(name)} [${absolutePath}] ${getStackTrace()}`);
3643
return Reflect.apply(target, thisArg, argumentsList);
3744

3845
},
3946
});
4047

41-
function resolve(modulePath: string, dir: string, parentModule: any): string {
48+
/**
49+
* Resolves a module path to an absolute path
50+
* @param modulePath - The module path to resolve
51+
* @param dir - The directory to resolve the module path in
52+
* @param parentModule - The parent module to resolve the module path in
53+
* @returns The absolute path of the module
54+
*/
55+
function resolve(
56+
modulePath: string,
57+
dir: string,
58+
parentModule: NodeJS.Module|null|undefined
59+
): string {
4260

4361
// if path starts with ., then it's relative
4462
if (modulePath.slice(0, 1) === '.') {
4563

4664
const resolvedAbsPath = path.resolve(dir, modulePath);
4765

48-
// eslint-disable-next-line no-underscore-dangle
4966
return Module._resolveFilename(resolvedAbsPath, parentModule);
5067

5168
}
5269

53-
// eslint-disable-next-line no-underscore-dangle
5470
return Module._resolveFilename(modulePath, parentModule);
5571

5672
}
5773

58-
function registerMockModules(mockModules: any, dir: string, parentModule: any) {
74+
function registerDepsToReplace(
75+
mockModules: any,
76+
dir: string,
77+
parentModule: NodeJS.Module|null|undefined,
78+
targetModulePath: string
79+
) {
5980

6081
Object.entries(mockModules).forEach((mockModule: any) => {
6182

6283
const [modulePath, mockReturnValue] = mockModule;
6384
const absolutePath = resolve(modulePath, dir, parentModule);
6485

65-
debug(`registerMocks(): ${modulePath} [${absolutePath}]`);
66-
67-
if (!absolutePath) {
68-
69-
throw new Error(`Unable to find module "${modulePath}".`);
86+
debug(`will replace: ${modulePath} [${absolutePath}]`);
7087

71-
}
72-
73-
registeredMocks.set(absolutePath, {
74-
modulePath,
88+
depsToMock.set(absolutePath, {
89+
dependencyPath: modulePath,
7590
mockReturnValue,
91+
parentPath: targetModulePath, // This is the module being mocked
7692
});
7793

7894
});
7995

8096
}
8197

82-
export function mock(modulePath: string, mocks: Record<string, any> = {}) {
98+
export function mock(modulePath: string, deps: Record<string, any> = {}) {
8399

84100
const callerFile = callsites()[1].getFileName() as string;
85-
const parentModule = module.parent?.parent;
101+
const callerModule = Object.values(Module._cache).find((mod: any) => mod.filename === callerFile) as NodeJS.Module
102+
?? module.parent?.parent; // this fallback assumes the caller is two levels up (mock.ts -> index.ts -> caller)
86103
const dir = path.dirname(callerFile);
87-
const absolutePath = resolve(modulePath, dir, parentModule);
104+
const absolutePath = resolve(modulePath, dir, callerModule);
88105
const moduleDir = path.dirname(absolutePath);
89106

90-
debug(`mock(): ${modulePath} [${absolutePath}]`);
91-
92-
if (!absolutePath) {
93-
94-
throw new Error(`Unable to find ${modulePath}`);
95-
96-
}
107+
debug(`mocking: ${modulePath} [${absolutePath}]`);
97108

98-
registerMockModules(mocks, moduleDir, parentModule);
109+
// Pass the absolutePath as the targetModulePath
110+
registerDepsToReplace(deps, moduleDir, callerModule, absolutePath);
99111
delete require.cache[absolutePath];
100112

101-
// eslint-disable-next-line @typescript-eslint/no-require-imports,@typescript-eslint/no-var-requires
113+
// require the module that we're mocking
102114
const mod = require(absolutePath);
103115

104116
// make sure there are no unused mocks
105-
if (registeredMocks.size) {
117+
if (depsToMock.size) {
106118

107119
throw new Error(`The following imports were not found in ${modulePath}:
108-
${[...registeredMocks.values()].map((mock) => mock.modulePath).join(', ')}`);
120+
${[...depsToMock.values()].map((mock) => mock.dependencyPath).join(', ')}`);
109121

110122
}
111123

@@ -124,13 +136,14 @@ function getStackTrace() {
124136

125137
const file = callsite.getFileName();
126138

139+
// filter out internal, node_modules, and cjs-mock files
127140
return file
128-
&& !file.includes('internal')
129-
&& !file.includes('node_modules')
130-
&& !file.includes('cjs-mock');
141+
&& !file.includes('internal')
142+
&& !file.includes('node_modules')
143+
&& !file.includes('cjs-mock');
131144

132145
})
133-
.map((callsite) => grey(` at ${callsite.getFileName()} ${callsite.getLineNumber()}:${callsite.getColumnNumber()}`))
146+
.map((callsite) => gray(` at ${callsite.getFileName()} ${callsite.getLineNumber()}:${callsite.getColumnNumber()}`))
134147
.join('\n');
135148

136149
return trace ? `\n${trace}` : '';

0 commit comments

Comments
 (0)