Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create max-nested-describe rule #845

Merged
merged 14 commits into from Jul 21, 2021
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -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][] | |
Expand Down
111 changes: 111 additions & 0 deletions docs/rules/max-nested-describe.md
@@ -0,0 +1,111 @@
# 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": 2 } `):

```js
describe('foo', () => {
describe('bar', () => {
describe('baz', () => {
it('should get something', () => {
expect(getSomething().toBe('Something'));
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
});

describe('foo2', function () {
describe('bar2', function () {
describe('baz2', function () {
it('should get something', () => {
expect(getSomething().toBe('Something'));
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
});
```

The following patterns are **not** considered warnings (with the default option
of `{ "max": 2 } `):

```js
describe('foo', () => {
describe('bar', () => {
it('should get something', () => {
expect(getSomething().toBe('Something'));
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
});
});

describe('qux', () => {
it('should get something', () => {
expect(getSomething().toBe('Something'));
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
});
});
});

describe('foo2', function () {
it('should get something', () => {
expect(getSomething().toBe('Something'));
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
});
});
```

## Options

```json
{
"jest/max-nested-describe": [
"error",
{
"max": 3
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
}
]
}
```

### `max`

Enforces a maximum depth for nested `describe()`.

This has default value of `2`.

Examples of patterns **not** considered warnings with options set to
`{ "max": 3 }`:

```js
describe('foo', () => {
describe('bar', () => {
describe('baz', () => {
it('should get something', () => {
expect(getSomething().toBe('Something'))
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
});

describe('foo2', function()) {
describe('bar2', function() {
describe('baz2', function() {
it('should get something', function() {
expect(getSomething().toBe('Something'))
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
});
});

describe('qux2', function() {
it('should get something', function() {
expect(getSomething().toBe('Something'))
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
});

```
135 changes: 135 additions & 0 deletions src/rules/__tests__/max-nested-describe.test.ts
@@ -0,0 +1,135 @@
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, {
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
valid: [
dedent`
describe('foo', () => {
describe('bar', () => {
it('hello', async () => {
expect('hello').toBe('hello');
});
});
});
`,
dedent`
describe('foo', () => {
describe('bar', () => {
it('hello', async () => {
expect('hello').toBe('hello');
});
});

describe('qux', () => {
it('something', async () => {
expect('something').toBe('something');
});
});
});
`,
dedent`
describe('foo', () => {
describe('bar', () => {
it('hello', async () => {
expect('hello').toBe('hello');
});
});
});

describe('foo', function() {
describe('bar', function() {
it('something', async () => {
expect('something').toBe('something');
});
});
});
`,
{
code: dedent`
describe('foo', () => {
describe('bar', () => {
describe('baz', () => {
it('something', async () => {
expect('something').toBe('something');
});
});
});
});
`,
options: [{ max: 3 }],
},
],
invalid: [
{
code: dedent`
describe('foo', function() {
describe('bar', function () {
describe('baz', function () {
describe('qux', function () {
it('should get something', () => {
expect(getSomething().toBe('Something'))
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
});
})
})
})
});
`,
errors: [
{ messageId: 'exceededMaxDepth', line: 3, column: 5 },
{ messageId: 'exceededMaxDepth', line: 4, column: 7 },
],
},
{
code: dedent`
describe('foo', () => {
describe('bar', () => {
describe('baz', () => {
it('should get something', () => {
expect(getSomething().toBe('Something'))
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
});
});

describe('qux', function () {
it('should get something', () => {
expect(getSomething().toBe('Something'))
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
});
});
})
});
`,
errors: [
{ messageId: 'exceededMaxDepth', line: 3, column: 5 },
{ messageId: 'exceededMaxDepth', line: 9, column: 5 },
],
},
{
code: dedent`
describe('foo', () => {
describe('bar', () => {
describe('baz', () => {
it('should get something', () => {
expect(getSomething().toBe('Something'))
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
});

describe('qux', () => {
it('should get something', () => {
expect(getSomething().toBe('Something'))
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
});
});
`,
errors: [{ messageId: 'exceededMaxDepth', line: 3, column: 5 }],
},
],
});
79 changes: 79 additions & 0 deletions src/rules/max-nested-describe.ts
@@ -0,0 +1,79 @@
import { TSESTree } from '@typescript-eslint/experimental-utils';
import { createRule, isCallExpression, 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}}.',
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
},
type: 'suggestion',
schema: [
{
oneOf: [
{
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
type: 'integer',
minimum: 0,
},
{
type: 'object',
properties: {
max: {
type: 'integer',
minimum: 0,
},
},
additionalProperties: false,
},
],
},
],
},
defaultOptions: [{ max: 2 }],
create(context, [{ max }]) {
const describeCallbackStack: number[] = [];

function pushDescribeCallback(
node: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
) {
const { parent } = node;

if (!isCallExpression(parent) || !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 (isCallExpression(parent) && isDescribeCall(parent)) {
describeCallbackStack.pop();
}
}

return {
FunctionExpression: pushDescribeCallback,
'FunctionExpression:exit': popDescribeCallback,
ArrowFunctionExpression: pushDescribeCallback,
'ArrowFunctionExpression:exit': popDescribeCallback,
};
},
});
10 changes: 10 additions & 0 deletions src/rules/utils.ts
Expand Up @@ -648,6 +648,16 @@ export type FunctionExpression =
| TSESTree.ArrowFunctionExpression
| TSESTree.FunctionExpression;

export const isCallExpression = (
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
node: TSESTree.Node | undefined,
): node is TSESTree.CallExpression => {
if (!node) {
return false;
}

return node.type === AST_NODE_TYPES.CallExpression;
};

export const isFunction = (node: TSESTree.Node): node is FunctionExpression =>
node.type === AST_NODE_TYPES.FunctionExpression ||
node.type === AST_NODE_TYPES.ArrowFunctionExpression;
Expand Down