Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1d32972
feat(rule): add new rule to validate `jest.mock` path existence
hainenber Nov 8, 2025
9ce0a40
chore: update rules.test.ts due to newly added rule
hainenber Nov 8, 2025
621b8da
chore: rewrite implementation to return early when linting over unwan…
hainenber Nov 8, 2025
c7d1901
fix(ci): verify broken npmjs link in MD file by checking its `registr…
hainenber Nov 8, 2025
fee7e7a
build(dev-deps): update `markdown-link-check`
hainenber Nov 8, 2025
bf6127f
feat: apply suggestions from @G-Rath code review
hainenber Nov 10, 2025
172594c
Revert "fix(ci): verify broken npmjs link in MD file by checking its …
hainenber Nov 10, 2025
4fb2681
Revert "build(dev-deps): update `markdown-link-check`"
hainenber Nov 10, 2025
80063e6
feat: change rule name + add tests + configurable module file extensi…
hainenber Nov 11, 2025
a6782da
Merge branch 'main' into feat/validate-mocked-module-path
hainenber Nov 11, 2025
4fab92e
Apply suggestions from @G-Rath's code review
hainenber Nov 12, 2025
09da117
Apply 2nd suggestion from @G-Rath
hainenber Nov 12, 2025
e92eb10
feat: recast err to `unknown` + refactor to return happy path asap
hainenber Nov 12, 2025
d60997c
chore: rerun `yarn regenerate-docs`
hainenber Nov 12, 2025
36d37a3
Update src/rules/valid-mock-module-path.ts
hainenber Nov 14, 2025
cb5947e
feat(rule/valid-mock-module-path): add test to cover code branch that…
hainenber Nov 18, 2025
8c9730b
chore: fix correct config to test `valid-mock-module-path` on eslint v9
hainenber Nov 18, 2025
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
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-mock-module-path](docs/rules/valid-mock-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
73 changes: 73 additions & 0 deletions docs/rules/valid-mock-module-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Disallow mocking of non-existing module path (`valid-mock-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');
```

## Options

```json
{
"jest/valid-mock-module-path": [
"error",
{
"moduleFileExtensions": [".tsx", ".ts"]
}
]
}
```

### `moduleFileExtensions`

This array option controls which file extensions the plugin checks for
existence. Valid values are:

- `".js"`
- `".ts"`
- `".jsx"`
- `".tsx"`
- `".json"`

For any custom extension, a preceding dot **must** be present before the file
extension for desired effect.

The default value for this option is
`{ "moduleFileExtensions": [".js", ".ts", ".jsx", ".tsx", ".json"] }`.

## 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-mock-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-mock-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';
Empty file.
Empty file.
115 changes: 115 additions & 0 deletions src/rules/__tests__/valid-mock-module-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import dedent from 'dedent';
import rule from '../valid-mock-module-path';
import { FlatCompatRuleTester as RuleTester, espreeParser } from './test-utils';

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

ruleTester.run('valid-mock-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")',
'jest.mock(() => {})',
{
filename: __filename,
code: dedent`
const a = "../module/does/not/exist";
jest.mock(a);
`,
},
{ filename: __filename, code: 'jest.mock("./fixtures/module/jsx/foo")' },
{ filename: __filename, code: 'jest.mock("./fixtures/module/tsx/foo")' },
{
filename: __filename,
code: 'jest.mock("./fixtures/module/tsx/foo")',
options: [{ moduleFileExtensions: ['.jsx'] }],
},
],
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("./fixtures/module/foo.jsx")',
options: [{ moduleFileExtensions: ['.tsx'] }],
errors: [
{
messageId: 'invalidMockModulePath',
data: { moduleName: '"./fixtures/module/foo.jsx"' },
column: 1,
line: 1,
},
],
},
{
filename: __filename,
code: 'jest.mock("./fixtures/module/foo.jsx")',
options: [{ moduleFileExtensions: undefined }],
errors: [
{
messageId: 'invalidMockModulePath',
data: { moduleName: '"./fixtures/module/foo.jsx"' },
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;
};
121 changes: 121 additions & 0 deletions src/rules/valid-mock-module-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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<
[
Partial<{
moduleFileExtensions: readonly string[];
}>,
],
'invalidMockModulePath'
>({
name: __filename,
meta: {
type: 'problem',
docs: {
description: 'Disallow mocking of non-existing module path',
},
messages: {
invalidMockModulePath: 'Module path {{ moduleName }} does not exist',
},
schema: [
{
type: 'object',
properties: {
moduleFileExtensions: {
type: 'array',
items: { type: 'string' },
additionalItems: false,
},
},
additionalProperties: false,
},
],
},
defaultOptions: [
{
moduleFileExtensions: ['.js', '.ts', '.tsx', '.jsx', '.json'],
},
],
create(
context,
[{ moduleFileExtensions = ['.js', '.ts', '.tsx', '.jsx', '.json'] }],
) {
return {
CallExpression(node: TSESTree.CallExpression): void {
if (node.callee.type !== AST_NODE_TYPES.MemberExpression) {
return;
}

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

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

if (!moduleName) {
return;
}

try {
if (!moduleName.value.startsWith('.')) {
require.resolve(moduleName.value);

return;
}

const resolvedModulePath = path.resolve(
path.dirname(context.filename),
moduleName.value,
);

const hasPossiblyModulePaths = ['', ...moduleFileExtensions].some(
ext => {
try {
statSync(`${resolvedModulePath}${ext}`);

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

if (!hasPossiblyModulePaths) {
throw { code: 'MODULE_NOT_FOUND' };
}
} catch (err: any) {
// Reports 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)) {
throw new Error(
`Error when trying to validate mock module path from \`jest.mock\`: ${err}`,
);
}

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