Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
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'] }],
Copy link
Collaborator

Choose a reason for hiding this comment

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

can we add a few more tests for this option - we should have one for .json, and it would be good to have one that adds e.g. .css which'll serve to show both arbitrary extensions work and that it replaces rather than extends the default

(I know you've already got at least one test covering the latter, but its still nice to have another)

) {
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' };
}
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 cute trick I'd not considered, but I'd like to get rid of the err: any and its best not to use errors for control flow - we should instead be able to move the context.report outside of the catch and use an early return to avoid triggering it

} catch (err: any) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd prefer we stick with unknown rather than any, and use a cast to access code.

That's slightly safer because we can't mistakenly use err elsewhere without more casting

// 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,
});
}
},
};
},
});