diff --git a/README.md b/README.md index cc8d97423..1b744f1bb 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ installations requiring long-term consistency. | [consistent-test-it](docs/rules/consistent-test-it.md) | Have control over `test` and `it` usages | | ![fixable][] | | [expect-expect](docs/rules/expect-expect.md) | Enforce assertion to be made in a test body | ![recommended][] | | | [lowercase-name](docs/rules/lowercase-name.md) | Enforce lowercase test names | | ![fixable][] | +| [max-nested-describe](docs/rules/max-nested-describe.md) | Enforces a maximum depth to nested describe calls | | | | [no-alias-methods](docs/rules/no-alias-methods.md) | Disallow alias methods | ![style][] | ![fixable][] | | [no-commented-out-tests](docs/rules/no-commented-out-tests.md) | Disallow commented out tests | ![recommended][] | | | [no-conditional-expect](docs/rules/no-conditional-expect.md) | Prevent calling `expect` conditionally | ![recommended][] | | diff --git a/docs/rules/max-nested-describe.md b/docs/rules/max-nested-describe.md new file mode 100644 index 000000000..a0770e316 --- /dev/null +++ b/docs/rules/max-nested-describe.md @@ -0,0 +1,131 @@ +# Enforces a maximum depth to nested describe calls (`max-nested-describe`) + +While it's useful to be able to group your tests together within the same file +using `describe()`, having too many levels of nesting throughout your tests make +them difficult to read. + +## Rule Details + +This rule enforces a maximum depth to nested `describe()` calls to improve code +clarity in your tests. + +The following patterns are considered warnings (with the default option of +`{ "max": 5 } `): + +```js +describe('foo', () => { + describe('bar', () => { + describe('baz', () => { + describe('qux', () => { + describe('quxx', () => { + describe('too many', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }); + }); + }); + }); +}); + +describe('foo', function () { + describe('bar', function () { + describe('baz', function () { + describe('qux', function () { + describe('quxx', function () { + describe('too many', function () { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }); + }); + }); + }); +}); +``` + +The following patterns are **not** considered warnings (with the default option +of `{ "max": 5 } `): + +```js +describe('foo', () => { + describe('bar', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + + describe('qux', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); +}); + +describe('foo2', function () { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); +}); + +describe('foo', function () { + describe('bar', function () { + describe('baz', function () { + describe('qux', function () { + describe('this is the limit', function () { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }); + }); + }); +}); +``` + +## Options + +```json +{ + "jest/max-nested-describe": [ + "error", + { + "max": 5 + } + ] +} +``` + +### `max` + +Enforces a maximum depth for nested `describe()`. + +This has a default value of `5`. + +Examples of patterns **not** considered warnings with options set to +`{ "max": 2 }`: + +```js +describe('foo', () => { + describe('bar', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); +}); + +describe('foo2', function()) { + describe('bar2', function() { + it('should get something', function() { + expect(getSomething()).toBe('Something'); + }); + + it('should get else', function() { + expect(getSomething()).toBe('Something'); + }); + }); +}); + +``` diff --git a/src/__tests__/__snapshots__/rules.test.ts.snap b/src/__tests__/__snapshots__/rules.test.ts.snap index 414f67032..eb3178266 100644 --- a/src/__tests__/__snapshots__/rules.test.ts.snap +++ b/src/__tests__/__snapshots__/rules.test.ts.snap @@ -13,6 +13,7 @@ Object { "jest/consistent-test-it": "error", "jest/expect-expect": "error", "jest/lowercase-name": "error", + "jest/max-nested-describe": "error", "jest/no-alias-methods": "error", "jest/no-commented-out-tests": "error", "jest/no-conditional-expect": "error", diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 6a8ff2df1..6703e7a13 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 = 45; +const numberOfRules = 46; const ruleNames = Object.keys(plugin.rules); const deprecatedRules = Object.entries(plugin.rules) .filter(([, rule]) => rule.meta.deprecated) diff --git a/src/rules/__tests__/max-nested-describe.test.ts b/src/rules/__tests__/max-nested-describe.test.ts new file mode 100644 index 000000000..b8e017357 --- /dev/null +++ b/src/rules/__tests__/max-nested-describe.test.ts @@ -0,0 +1,223 @@ +import { TSESLint } from '@typescript-eslint/experimental-utils'; +import dedent from 'dedent'; +import resolveFrom from 'resolve-from'; +import rule from '../max-nested-describe'; + +const ruleTester = new TSESLint.RuleTester({ + parser: resolveFrom(require.resolve('eslint'), 'espree'), + parserOptions: { + ecmaVersion: 2017, + }, +}); + +ruleTester.run('max-nested-describe', rule, { + valid: [ + dedent` + describe('foo', function() { + describe('bar', function () { + describe('baz', function () { + describe('qux', function () { + describe('qux', function () { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }) + }) + }) + }) + }); + `, + dedent` + describe('foo', function() { + describe('bar', function () { + describe('baz', function () { + describe('qux', function () { + describe('qux', function () { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + + fdescribe('qux', () => { + it('something', async () => { + expect('something').toBe('something'); + }); + }); + }) + }) + }) + }); + `, + dedent` + describe('foo', () => { + describe('bar', () => { + it('hello', async () => { + expect('hello').toBe('hello'); + }); + }); + }); + + xdescribe('foo', function() { + describe('bar', function() { + it('something', async () => { + expect('something').toBe('something'); + }); + }); + }); + `, + { + code: dedent` + describe('foo', () => { + describe.only('bar', () => { + describe.skip('baz', () => { + it('something', async () => { + expect('something').toBe('something'); + }); + }); + }); + }); + `, + options: [{ max: 3 }], + }, + { + code: dedent` + it('something', async () => { + expect('something').toBe('something'); + }); + `, + options: [{ max: 0 }], + }, + dedent` + describe('foo', () => { + describe.each(['hello', 'world'])("%s", (a) => {}); + }); + `, + dedent` + describe('foo', () => { + describe.each\` + foo | bar + ${1} | ${2} + \`('$foo $bar', ({ foo, bar }) => {}); + }); + `, + ], + invalid: [ + { + code: dedent` + describe('foo', function() { + describe('bar', function () { + describe('baz', function () { + describe('qux', function () { + describe('quxx', function () { + describe('over limit', function () { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }); + }); + }); + }); + }); + `, + errors: [{ messageId: 'exceededMaxDepth', line: 6, column: 11 }], + }, + { + code: dedent` + describe('foo', () => { + describe('bar', () => { + describe('baz', () => { + describe('baz1', () => { + describe('baz2', () => { + describe('baz3', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + + describe('baz4', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }); + }); + }); + + describe('qux', function () { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }) + }); + `, + errors: [ + { messageId: 'exceededMaxDepth', line: 6, column: 11 }, + { messageId: 'exceededMaxDepth', line: 12, column: 11 }, + ], + }, + { + code: dedent` + fdescribe('foo', () => { + describe.only('bar', () => { + describe.skip('baz', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + + describe('baz', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }); + }); + + xdescribe('qux', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + `, + options: [{ max: 2 }], + errors: [ + { messageId: 'exceededMaxDepth', line: 3, column: 5 }, + { messageId: 'exceededMaxDepth', line: 9, column: 5 }, + ], + }, + { + code: dedent` + describe('qux', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + `, + options: [{ max: 0 }], + errors: [{ messageId: 'exceededMaxDepth', line: 1, column: 1 }], + }, + { + code: dedent` + describe('foo', () => { + describe.each(['hello', 'world'])("%s", (a) => {}); + }); + `, + options: [{ max: 1 }], + errors: [{ messageId: 'exceededMaxDepth', line: 2, column: 3 }], + }, + { + code: dedent` + describe('foo', () => { + describe.each\` + foo | bar + ${1} | ${2} + \`('$foo $bar', ({ foo, bar }) => {}); + }); + `, + options: [{ max: 1 }], + errors: [{ messageId: 'exceededMaxDepth', line: 2, column: 3 }], + }, + ], +}); diff --git a/src/rules/max-nested-describe.ts b/src/rules/max-nested-describe.ts new file mode 100644 index 000000000..d273072d5 --- /dev/null +++ b/src/rules/max-nested-describe.ts @@ -0,0 +1,80 @@ +import { + AST_NODE_TYPES, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import { createRule, isDescribeCall } from './utils'; + +export default createRule({ + name: __filename, + meta: { + docs: { + category: 'Best Practices', + description: 'Enforces a maximum depth to nested describe calls', + recommended: false, + }, + messages: { + exceededMaxDepth: + 'Too many nested describe calls ({{ depth }}). Maximum allowed is {{ max }}.', + }, + type: 'suggestion', + schema: [ + { + type: 'object', + properties: { + max: { + type: 'integer', + minimum: 0, + }, + }, + additionalProperties: false, + }, + ], + }, + defaultOptions: [{ max: 5 }], + create(context, [{ max }]) { + const describeCallbackStack: number[] = []; + + function pushDescribeCallback( + node: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression, + ) { + const { parent } = node; + + if ( + parent?.type !== AST_NODE_TYPES.CallExpression || + !isDescribeCall(parent) + ) { + return; + } + + describeCallbackStack.push(0); + + if (describeCallbackStack.length > max) { + context.report({ + node: parent, + messageId: 'exceededMaxDepth', + data: { depth: describeCallbackStack.length, max }, + }); + } + } + + function popDescribeCallback( + node: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression, + ) { + const { parent } = node; + + if ( + parent?.type === AST_NODE_TYPES.CallExpression && + isDescribeCall(parent) + ) { + describeCallbackStack.pop(); + } + } + + return { + FunctionExpression: pushDescribeCallback, + 'FunctionExpression:exit': popDescribeCallback, + ArrowFunctionExpression: pushDescribeCallback, + 'ArrowFunctionExpression:exit': popDescribeCallback, + }; + }, +});