Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ Manually fixable by
| [valid-describe-callback](docs/rules/valid-describe-callback.md) | Enforce valid `describe()` callback | ✅ | | | |
| [valid-expect](docs/rules/valid-expect.md) | Enforce valid `expect()` usage | ✅ | | 🔧 | |
| [valid-expect-in-promise](docs/rules/valid-expect-in-promise.md) | Require promises that have expectations in their chain to be valid | ✅ | | | |
| [valid-mocked-module-path](docs/rules/valid-mocked-module-path.md) | Disallow mocking of non-existing module path | | | | |
| [valid-title](docs/rules/valid-title.md) | Enforce valid titles | ✅ | | 🔧 | |

### Requires Type Checking
Expand Down
43 changes: 43 additions & 0 deletions docs/rules/valid-mocked-module-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Disallow mocking of non-existing module path (`valid-mocked-module-path`)

<!-- end auto-generated rule header -->

This rule raises an error when using `jest.mock` and `jest.doMock` and the first
argument for mocked object (module/local file) do not exist.

## Rule details

This rule checks existence of the supplied path for `jest.mock` or `jest.doMock`
in the first argument.

The following patterns are considered errors:

```js
// Module(s) that cannot be found
jest.mock('@org/some-module-not-in-package-json');
jest.mock('some-module-not-in-package-json');

// Local module (directory) that cannot be found
jest.mock('../../this/module/does/not/exist');

// Local file that cannot be found
jest.mock('../../this/path/does/not/exist.js');
```

The following patterns are **not** considered errors:

```js
// Module(s) that can be found
jest.mock('@org/some-module-in-package-json');
jest.mock('some-module-in-package-json');

// Local module that cannot be found
jest.mock('../../this/module/really/does/exist');

// Local file that cannot be found
jest.mock('../../this/path/really/does/exist.js');
```

## When Not To Use It

Don't use this rule on non-jest test files.
2 changes: 2 additions & 0 deletions src/__tests__/__snapshots__/rules.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ exports[`rules should export configs that refer to actual rules 1`] = `
"jest/valid-describe-callback": "error",
"jest/valid-expect": "error",
"jest/valid-expect-in-promise": "error",
"jest/valid-mocked-module-path": "error",
"jest/valid-title": "error",
},
},
Expand Down Expand Up @@ -164,6 +165,7 @@ exports[`rules should export configs that refer to actual rules 1`] = `
"jest/valid-describe-callback": "error",
"jest/valid-expect": "error",
"jest/valid-expect-in-promise": "error",
"jest/valid-mocked-module-path": "error",
"jest/valid-title": "error",
},
},
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync } from 'fs';
import { resolve } from 'path';
import plugin from '../';

const numberOfRules = 63;
const numberOfRules = 64;
const ruleNames = Object.keys(plugin.rules);
const deprecatedRules = Object.entries(plugin.rules)
.filter(([, rule]) => rule.meta.deprecated)
Expand Down
1 change: 1 addition & 0 deletions src/rules/__tests__/fixtures/module/foo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = 'foo_js';
1 change: 1 addition & 0 deletions src/rules/__tests__/fixtures/module/foo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = 'foo_ts';
1 change: 1 addition & 0 deletions src/rules/__tests__/fixtures/module/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './foo';
74 changes: 74 additions & 0 deletions src/rules/__tests__/valid-mocked-module-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import dedent from 'dedent';
import rule from '../valid-mocked-module-path';
import { FlatCompatRuleTester as RuleTester, espreeParser } from './test-utils';

const ruleTester = new RuleTester({
parser: espreeParser,
parserOptions: {
ecmaVersion: 2015,
},
});

ruleTester.run('valid-mocked-module-path', rule, {
valid: [
{ filename: __filename, code: 'jest.mock("./fixtures/module")' },
{ filename: __filename, code: 'jest.mock("./fixtures/module", () => {})' },
{ filename: __filename, code: 'jest.mock()' },
{
filename: __filename,
code: 'jest.doMock("./fixtures/module", () => {})',
},
{
filename: __filename,
code: dedent`
describe("foo", () => {});
`,
},
{ filename: __filename, code: 'jest.doMock("./fixtures/module")' },
{ filename: __filename, code: 'jest.mock("./fixtures/module/foo.ts")' },
{ filename: __filename, code: 'jest.doMock("./fixtures/module/foo.ts")' },
{ filename: __filename, code: 'jest.mock("./fixtures/module/foo.js")' },
{ filename: __filename, code: 'jest.doMock("./fixtures/module/foo.js")' },
'jest.mock("eslint")',
'jest.doMock("eslint")',
'jest.mock("child_process")',
],
invalid: [
{
filename: __filename,
code: "jest.mock('../module/does/not/exist')",
errors: [
{
messageId: 'invalidMockModulePath',
data: { moduleName: "'../module/does/not/exist'" },
column: 1,
line: 1,
},
],
},
{
filename: __filename,
code: 'jest.mock("../file/does/not/exist.ts")',
errors: [
{
messageId: 'invalidMockModulePath',
data: { moduleName: '"../file/does/not/exist.ts"' },
column: 1,
line: 1,
},
],
},
{
filename: __filename,
code: 'jest.mock("@doesnotexist/module")',
errors: [
{
messageId: 'invalidMockModulePath',
data: { moduleName: '"@doesnotexist/module"' },
column: 1,
line: 1,
},
],
},
],
});
11 changes: 1 addition & 10 deletions src/rules/no-untyped-mock-factory.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
import {
createRule,
findModuleName,
getAccessorValue,
isFunction,
isSupportedAccessor,
isTypeOfJestFnCall,
} from './utils';

const findModuleName = (
node: TSESTree.Literal | TSESTree.Node,
): TSESTree.StringLiteral | null => {
if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') {
return node;
}

return null;
};

export default createRule({
name: __filename,
meta: {
Expand Down
10 changes: 10 additions & 0 deletions src/rules/utils/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,13 @@ export const getFirstMatcherArg = (

return followTypeAssertionChain(firstArg);
};

export const findModuleName = (
node: TSESTree.Literal | TSESTree.Node,
): TSESTree.StringLiteral | null => {
if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') {
return node;
}

return null;
};
98 changes: 98 additions & 0 deletions src/rules/valid-mocked-module-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { statSync } from 'fs';
import path from 'path';
import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
import {
createRule,
findModuleName,
getAccessorValue,
isSupportedAccessor,
isTypeOfJestFnCall,
} from './utils';

export default createRule({
name: __filename,
meta: {
type: 'problem',
docs: {
description: 'Disallow mocking of non-existing module path',
},
messages: {
invalidMockModulePath: 'Module path {{ moduleName }} does not exist',
},
schema: [],
},
defaultOptions: [],
create(context) {
return {
CallExpression(node: TSESTree.CallExpression): void {
const { callee } = node;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I don't think it's worth destructing this or property into variables given they're only used a couple of times


if (callee.type !== AST_NODE_TYPES.MemberExpression) {
return;
}

const { property } = callee;

if (
!node.arguments.length ||
!isTypeOfJestFnCall(node, context, ['jest']) ||
!(
isSupportedAccessor(property) &&
['mock', 'doMock'].includes(getAccessorValue(property))
)
) {
return;
}

const moduleName = findModuleName(node.arguments[0]);

/* istanbul ignore if */
if (!moduleName) {
throw new Error(
'Cannot parse mocked module name from `jest.mock` - - please file a github issue at https://github.com/jest-community/eslint-plugin-jest`',
);
}
Comment on lines +50 to +54
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a very possible path as the requirement is "something other than a string is passed as the first argument", which includes variables but also silly stuff like an inline function (i.e. jest.mock(() => {}))


try {
if (moduleName.value.startsWith('.')) {
const resolvedModulePath = path.resolve(
path.dirname(context.filename),
moduleName.value,
);

const hasPossiblyModulePaths = ['', '.js', '.ts']
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be configurable, and I'm 98% sure this is related to moduleFileExtensions, so it might be best to have Jest v30s default as our default

(technically moduleNameMapper matters too, but that's complex enough that I think we can save that option for another time)

@SimenB can you confirm if I'm right?

.map(ext => `${resolvedModulePath}${ext}`)
.some(modPath => {
try {
statSync(modPath);

return true;
} catch {
return false;
}
});

if (!hasPossiblyModulePaths) {
throw { code: 'MODULE_NOT_FOUND' };
}
} else {
require.resolve(moduleName.value);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can do an early return to avoid this else, and if you invert the condition we can deindent the other (larger) code path

}
} catch (err: any) {
// Skip over any unexpected issues when attempt to verify mocked module path.
// The list of possible errors is non-exhaustive.
/* istanbul ignore if */
if (!['MODULE_NOT_FOUND', 'ENOENT'].includes(err.code)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should be swallowing all other errors like this - we should either report or rethrow.

The two situations I can think of where we'd have an error is a bug in our code, or an issue with accessing the path.

Ideally for the former we'd want to rethrow, but the latter we probably want to report since its reasonable to expect Jest itself would have the same error - unfortunately I don't think we can reliably distinguish those situations so we probably just need to pick one action and change it later if it turns out to be noisy

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay so I think I'll change the code to rethrow unexpected errors since there are only fs and path-related errors, end user can resolve (no pun intended) by themselves.

return;
}

context.report({
messageId: 'invalidMockModulePath',
data: { moduleName: moduleName.raw },
node,
});
}
},
};
},
});