diff --git a/README.md b/README.md index 5335a38..c8b9df2 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ To enable this configuration with `.eslintrc`, use the `extends` property: | Name                             | Description | 💼 | | :--------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------- | :- | +| [no-long-arrays-in-test-each](docs/rules/no-long-arrays-in-test-each.md) | Disallow mixing expectations for different variables between each other. | ✅ | | [no-mixed-expectation-groups](docs/rules/no-mixed-expectation-groups.md) | Disallow mixing expectations for different variables between each other. | ✅ | | [no-useless-matcher-to-be-defined](docs/rules/no-useless-matcher-to-be-defined.md) | Disallow using `.toBeDefined()` matcher when it is known that variable is always defined. | ✅ | | [no-useless-matcher-to-be-null](docs/rules/no-useless-matcher-to-be-null.md) | Disallow using `.toBeNull()` when TypeScript types conflict with it. | ✅ | diff --git a/docs/rules/no-long-arrays-in-test-each.md b/docs/rules/no-long-arrays-in-test-each.md new file mode 100644 index 0000000..efa8471 --- /dev/null +++ b/docs/rules/no-long-arrays-in-test-each.md @@ -0,0 +1,102 @@ +# Disallow mixing expectations for different variables between each other (`proper-tests/no-long-arrays-in-test-each`) + +💼 This rule is enabled in the ✅ `recommended` config. + + + +## Rule details + +This rule disallows mixing expectations for different variables between each other. + +The following code is considered errors: + +```ts +test.each([ + { + description: 'test case name #1', + inputValue: 'a', + expectedOutput: 'aa', + }, + { + description: 'test case name #2', + inputValue: 'b', + expectedOutput: 'bb', + }, + { + description: 'test case name #3', + inputValue: 'c', + expectedOutput: 'cc', + }, + { + description: 'test case name #4', + inputValue: 'd', + expectedOutput: 'dd', + }, + { + description: 'test case name #5', + inputValue: 'e', + expectedOutput: 'ee', + }, + { + description: 'test case name #6', + inputValue: 'f', + expectedOutput: 'ff', + }, +])('$description', ({ clientCountry, expectedPaymentMethod, processorName }) => { + // ... +}); +``` + +Consider extracting such long arrays to a separate files with for example `.data.ts` postfix. + +The following code is considered correct: + +```ts +// some-service.data.ts +export type TestCase = Readonly<{ + description: string; + inputValue: string; + expectedOutput: string; +}>; + +export const testCases: TestCase[] = [ + { + description: 'test case name #1', + inputValue: 'a', + expectedOutput: 'aa', + }, + { + description: 'test case name #2', + inputValue: 'b', + expectedOutput: 'bb', + }, + { + description: 'test case name #3', + inputValue: 'c', + expectedOutput: 'cc', + }, + { + description: 'test case name #4', + inputValue: 'd', + expectedOutput: 'dd', + }, + { + description: 'test case name #5', + inputValue: 'e', + expectedOutput: 'ee', + }, + { + description: 'test case name #6', + inputValue: 'f', + expectedOutput: 'ff', + }, +]; +``` + +and now test is more readable: + +```ts +test.each(testCases)('$description', ({ inputValue, expectedOutput }) => { + // ... +}); +``` diff --git a/src/configs/recommended.ts b/src/configs/recommended.ts index ab727a6..b081e1c 100644 --- a/src/configs/recommended.ts +++ b/src/configs/recommended.ts @@ -5,5 +5,6 @@ export = { 'proper-tests/no-useless-matcher-to-be-defined': 'error', 'proper-tests/no-useless-matcher-to-be-null': 'error', 'proper-tests/no-mixed-expectation-groups': 'error', + 'proper-tests/no-long-arrays-in-test-each': 'error', }, } satisfies ClassicConfig.Config; diff --git a/src/custom-rules/no-long-arrays-in-test-each.spec.ts b/src/custom-rules/no-long-arrays-in-test-each.spec.ts new file mode 100644 index 0000000..48869c0 --- /dev/null +++ b/src/custom-rules/no-long-arrays-in-test-each.spec.ts @@ -0,0 +1,130 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { noLongArraysInTestEach } from './no-long-arrays-in-test-each'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('no-long-arrays-in-test-each', noLongArraysInTestEach, { + valid: [ + { + name: 'less than default limit using "test"', + filename: 'app.e2e-spec.ts', + code: `test.each([{}])`, + }, + { + name: 'less than default limit using "it"', + filename: 'app.e2e-spec.ts', + code: `it.each([{}])`, + }, + { + name: 'with integers using "test"', + filename: 'app.e2e-spec.ts', + code: 'test.each([1, 2, 3, 4, 5, 6]);', + }, + { + name: 'with integers using "it"', + filename: 'app.e2e-spec.ts', + code: 'it.each([1, 2, 3, 4, 5, 6]);', + }, + { + name: 'with mixed integers and objects using "it"', + filename: 'app.e2e-spec.ts', + code: 'it.each([{}, {}, {}, {}, {}, 1, 2, 3, 4, 5]);', + }, + { + name: 'with 6 objects when option is overridden using "it"', + filename: 'app.e2e-spec.ts', + options: [{ limit: 7 }], + code: 'it.each([{}, {}, {}, {}, {}, {}]);', + }, + { + name: 'with 2 objects when option is overridden using "it"', + filename: 'app.e2e-spec.ts', + options: [{ limit: 4 }], + code: 'it.each([{}, {}]);', + }, + { + name: 'string contains it.each', + filename: 'app.e2e-spec.ts', + code: '"it.each([{}, {}])";', + }, + { + name: 'simple function call is executed', + filename: 'app.e2e-spec.ts', + code: 'each([{}, {}]);', + }, + { + name: 'neither test nor it object is used', + filename: 'app.e2e-spec.ts', + code: 'array.each([{}, {}]);', + }, + { + name: 'called function is not "each"', + filename: 'app.e2e-spec.ts', + code: 'test.every([{}, {}]);', + }, + { + name: 'function argument is not an array but string', + filename: 'app.e2e-spec.ts', + code: 'test.each("string");', + }, + { + name: 'not in e2e test file', + filename: 'non-e2e-test.ts', + code: 'it.each([{}, {}, {}, {}, {}]);', + }, + ], + invalid: [ + { + name: 'when 6 objects are passed while 5 are allowed by default using "test.each"', + filename: 'app.e2e-spec.ts', + code: 'test.each([{}, {}, {}, {}, {}, {}])', + output: null, + errors: [ + { + messageId: 'noLongArrays', + data: { + testFunctionName: 'test', + actualLength: 6, + limit: 5, + }, + }, + ], + }, + { + name: 'when 6 objects are passed while 5 are allowed by default using "it.each"', + filename: 'app.e2e-spec.ts', + code: 'it.each([{}, {}, {}, {}, {}, {}])', + output: null, + errors: [ + { + messageId: 'noLongArrays', + data: { + testFunctionName: 'it', + actualLength: 6, + limit: 5, + }, + }, + ], + }, + { + name: 'when 2 objects are passed while 1 is allowed by passed option using "it.each"', + filename: 'app.e2e-spec.ts', + options: [{ limit: 1 }], + code: 'it.each([{}, {}])', + output: null, + errors: [ + { + messageId: 'noLongArrays', + data: { + testFunctionName: 'it', + actualLength: 2, + limit: 1, + }, + }, + ], + }, + ], +}); diff --git a/src/custom-rules/no-long-arrays-in-test-each.ts b/src/custom-rules/no-long-arrays-in-test-each.ts new file mode 100644 index 0000000..06089e1 --- /dev/null +++ b/src/custom-rules/no-long-arrays-in-test-each.ts @@ -0,0 +1,84 @@ +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'; + +type MessageIds = 'noLongArrays'; +type Options = [{ limit?: number }]; + +const DEFAULT_LIMIT = 5; + +export const noLongArraysInTestEach = ESLintUtils.RuleCreator.withoutDocs({ + create(context, options) { + return { + CallExpression(node) { + if (node.callee.type !== AST_NODE_TYPES.MemberExpression) { + return; + } + + if ( + node.callee.object.type !== AST_NODE_TYPES.Identifier || + !['test', 'it'].includes(node.callee.object.name) + ) { + return; + } + + if (node.callee.property.type !== AST_NODE_TYPES.Identifier || node.callee.property.name !== 'each') { + return; + } + + const firstArgument = node.arguments[0]; + + if (firstArgument.type !== AST_NODE_TYPES.ArrayExpression) { + return; + } + + const limit = options[0].limit || DEFAULT_LIMIT; + const elements = firstArgument.elements; + + if (elements.length <= limit) { + return; + } + + // eslint-disable-next-line @typescript-eslint/typedef + const allElementsAreObjects = elements.every(element => element?.type === AST_NODE_TYPES.ObjectExpression); + + if (!allElementsAreObjects) { + return; + } + + context.report({ + node, + messageId: 'noLongArrays', + data: { + testFunctionName: node.callee.object.name, + actualLength: elements.length, + limit: limit, + }, + }); + }, + }; + }, + meta: { + docs: { + description: + 'Disallow using long arrays with objects inside `test.each()` or `it.each()`. Force moving them out of the file.', + }, + messages: { + // eslint-disable-next-line max-len + noLongArrays: + 'Move the array with objects out of the test file in `{{ testFunctionName }}.each()`. Array length is {{ actualLength }}, but the limit is {{ limit }} items.', + }, + type: 'suggestion', + schema: [ + { + type: 'object', + properties: { + limit: { + type: 'integer', + minimum: 1, + }, + }, + additionalProperties: false, + }, + ], + }, + defaultOptions: [{ limit: DEFAULT_LIMIT }], +}); diff --git a/src/custom-rules/no-mixed-expectation-groups.ts b/src/custom-rules/no-mixed-expectation-groups.ts index 524ae9f..7ae9a91 100644 --- a/src/custom-rules/no-mixed-expectation-groups.ts +++ b/src/custom-rules/no-mixed-expectation-groups.ts @@ -78,7 +78,7 @@ export const noMixedExpectationGroups = ESLintUtils.RuleCreator.withoutDocs { 'no-useless-matcher-to-be-defined', 'no-useless-matcher-to-be-null', 'no-mixed-expectation-groups', + 'no-long-arrays-in-test-each', ]); }); diff --git a/src/plugin.ts b/src/plugin.ts index acc8bff..f570042 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -13,5 +13,6 @@ export = { 'no-useless-matcher-to-be-defined': noUselessMatcherToBeDefined, 'no-useless-matcher-to-be-null': noUselessMatcherToBeNull, 'no-mixed-expectation-groups': noMixedExpectationGroups, + 'no-long-arrays-in-test-each': noMixedExpectationGroups, }, } satisfies Linter.Plugin;