diff --git a/README.md b/README.md index 79064c319..675e0e4d3 100644 --- a/README.md +++ b/README.md @@ -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 paths | | | | | | [valid-title](docs/rules/valid-title.md) | Enforce valid titles | ✅ | | 🔧 | | ### Requires Type Checking diff --git a/docs/rules/valid-mock-module-path.md b/docs/rules/valid-mock-module-path.md new file mode 100644 index 000000000..6dd13d45b --- /dev/null +++ b/docs/rules/valid-mock-module-path.md @@ -0,0 +1,70 @@ +# Disallow mocking of non-existing module paths (`valid-mock-module-path`) + + + +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": [".js", ".ts", ".jsx", ".tsx", ".json"] + } + ] +} +``` + +### `moduleFileExtensions` + +This array option controls which file extensions the plugin checks for +existence. The default extensions are: + +- `".js"` +- `".ts"` +- `".jsx"` +- `".tsx"` +- `".json"` + +For any custom extension, a preceding dot **must** be present before the file +extension for desired effect. + +## When Not To Use It + +Don't use this rule on non-jest test files. diff --git a/src/__tests__/__snapshots__/rules.test.ts.snap b/src/__tests__/__snapshots__/rules.test.ts.snap index cbd7a4236..6bf462676 100644 --- a/src/__tests__/__snapshots__/rules.test.ts.snap +++ b/src/__tests__/__snapshots__/rules.test.ts.snap @@ -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", }, }, @@ -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", }, }, diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 857f65a41..a1a9af556 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -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) diff --git a/src/rules/__tests__/fixtures/module/bar.css b/src/rules/__tests__/fixtures/module/bar.css new file mode 100644 index 000000000..c5d5cfc0d --- /dev/null +++ b/src/rules/__tests__/fixtures/module/bar.css @@ -0,0 +1,6 @@ +.bar-container { + background-color: #f5f5f5; + padding: 16px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} diff --git a/src/rules/__tests__/fixtures/module/bar.json b/src/rules/__tests__/fixtures/module/bar.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/rules/__tests__/fixtures/module/bar.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/rules/__tests__/fixtures/module/foo.js b/src/rules/__tests__/fixtures/module/foo.js new file mode 100644 index 000000000..b9627ca7e --- /dev/null +++ b/src/rules/__tests__/fixtures/module/foo.js @@ -0,0 +1 @@ +export const foo = 'foo_js'; \ No newline at end of file diff --git a/src/rules/__tests__/fixtures/module/foo.ts b/src/rules/__tests__/fixtures/module/foo.ts new file mode 100644 index 000000000..4221ba044 --- /dev/null +++ b/src/rules/__tests__/fixtures/module/foo.ts @@ -0,0 +1 @@ +export const foo = 'foo_ts'; \ No newline at end of file diff --git a/src/rules/__tests__/fixtures/module/index.ts b/src/rules/__tests__/fixtures/module/index.ts new file mode 100644 index 000000000..d6c3be183 --- /dev/null +++ b/src/rules/__tests__/fixtures/module/index.ts @@ -0,0 +1 @@ +export * from './foo'; diff --git a/src/rules/__tests__/fixtures/module/jsx/foo.jsx b/src/rules/__tests__/fixtures/module/jsx/foo.jsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/rules/__tests__/fixtures/module/tsx/foo.jsx b/src/rules/__tests__/fixtures/module/tsx/foo.jsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/rules/__tests__/valid-mock-module-path.test.ts b/src/rules/__tests__/valid-mock-module-path.test.ts new file mode 100644 index 000000000..87b89e685 --- /dev/null +++ b/src/rules/__tests__/valid-mock-module-path.test.ts @@ -0,0 +1,125 @@ +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'] }], + }, + { + filename: __filename, + code: 'jest.mock("./fixtures/module/bar")', + options: [{ moduleFileExtensions: ['.json'] }], + }, + { + filename: __filename, + code: 'jest.mock("./fixtures/module/bar")', + options: [{ moduleFileExtensions: ['.css'] }], + }, + ], + 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, + }, + ], + }, + ], +}); diff --git a/src/rules/no-untyped-mock-factory.ts b/src/rules/no-untyped-mock-factory.ts index 4f0448696..cebc26f33 100644 --- a/src/rules/no-untyped-mock-factory.ts +++ b/src/rules/no-untyped-mock-factory.ts @@ -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: { diff --git a/src/rules/utils/misc.ts b/src/rules/utils/misc.ts index 71c8c91e1..fb56c85c8 100644 --- a/src/rules/utils/misc.ts +++ b/src/rules/utils/misc.ts @@ -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; +}; diff --git a/src/rules/valid-mock-module-path.ts b/src/rules/valid-mock-module-path.ts new file mode 100644 index 000000000..77feabc08 --- /dev/null +++ b/src/rules/valid-mock-module-path.ts @@ -0,0 +1,123 @@ +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 paths', + }, + 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) { + return; + } + } catch (err: unknown) { + const castedErr = err as { code: string }; + + // Reports unexpected issues when attempt to verify mocked module path. + // The list of possible errors is non-exhaustive. + /* istanbul ignore if */ + if (castedErr.code !== 'MODULE_NOT_FOUND') { + throw new Error( + `Error when trying to validate mock module path from \`jest.mock\`: ${err}`, + ); + } + } + + context.report({ + messageId: 'invalidMockModulePath', + data: { moduleName: moduleName.raw }, + node, + }); + }, + }; + }, +});