Skip to content

Commit c497904

Browse files
committed
feat(rule): disallow unnecessary async function wrapper for single await call
Signed-off-by: hainenber <[email protected]>
1 parent 3a2272e commit c497904

File tree

7 files changed

+329
-66
lines changed

7 files changed

+329
-66
lines changed

README.md

Lines changed: 66 additions & 65 deletions
Large diffs are not rendered by default.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Disallow unnecessary async wrapper for expected promises (`no-async-wrapper-for-expected-promise`)
2+
3+
💼 This rule is enabled in the ✅ `recommended`
4+
[config](https://github.com/jest-community/eslint-plugin-jest/blob/main/README.md#shareable-configurations).
5+
6+
🔧 This rule is automatically fixable by the
7+
[`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
8+
9+
<!-- end auto-generated rule header -->
10+
11+
`Jest` can handle fulfilled/rejected promisified function call normally but
12+
occassionally, engineers wrap said function in another `async` function that is
13+
excessively verbose and make the tests harder to read.
14+
15+
## Rule details
16+
17+
This rule triggers a warning if a single `await` function call is wrapped by an
18+
unnecessary `async` function.
19+
20+
Examples of **incorrect** code for this rule
21+
22+
```js
23+
it('wrong1', async () => {
24+
await expect(async () => {
25+
await doSomethingAsync();
26+
}).rejects.toThrow();
27+
});
28+
29+
it('wrong2', async () => {
30+
await expect(async function () {
31+
await doSomethingAsync();
32+
}).rejects.toThrow();
33+
});
34+
```
35+
36+
Examples of **correct** code for this rule
37+
38+
```js
39+
it('right1', async () => {
40+
await expect(doSomethingAsync()).rejects.toThrow();
41+
});
42+
```

src/__tests__/__snapshots__/rules.test.ts.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ exports[`rules should export configs that refer to actual rules 1`] = `
1515
"jest/max-expects": "error",
1616
"jest/max-nested-describe": "error",
1717
"jest/no-alias-methods": "error",
18+
"jest/no-async-wrapper-for-expected-promise": "error",
1819
"jest/no-commented-out-tests": "error",
1920
"jest/no-conditional-expect": "error",
2021
"jest/no-conditional-in-test": "error",
@@ -108,6 +109,7 @@ exports[`rules should export configs that refer to actual rules 1`] = `
108109
"jest/max-expects": "error",
109110
"jest/max-nested-describe": "error",
110111
"jest/no-alias-methods": "error",
112+
"jest/no-async-wrapper-for-expected-promise": "error",
111113
"jest/no-commented-out-tests": "error",
112114
"jest/no-conditional-expect": "error",
113115
"jest/no-conditional-in-test": "error",
@@ -198,6 +200,7 @@ exports[`rules should export configs that refer to actual rules 1`] = `
198200
"rules": {
199201
"jest/expect-expect": "warn",
200202
"jest/no-alias-methods": "error",
203+
"jest/no-async-wrapper-for-expected-promise": "error",
201204
"jest/no-commented-out-tests": "warn",
202205
"jest/no-conditional-expect": "error",
203206
"jest/no-deprecated-functions": "error",
@@ -259,6 +262,7 @@ exports[`rules should export configs that refer to actual rules 1`] = `
259262
"rules": {
260263
"jest/expect-expect": "warn",
261264
"jest/no-alias-methods": "error",
265+
"jest/no-async-wrapper-for-expected-promise": "error",
262266
"jest/no-commented-out-tests": "warn",
263267
"jest/no-conditional-expect": "error",
264268
"jest/no-deprecated-functions": "error",

src/__tests__/rules.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { existsSync } from 'fs';
22
import { resolve } from 'path';
33
import plugin from '../';
44

5-
const numberOfRules = 64;
5+
const numberOfRules = 65;
66
const ruleNames = Object.keys(plugin.rules);
77
const deprecatedRules = Object.entries(plugin.rules)
88
.filter(([, rule]) => rule.meta.deprecated)

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const rules = Object.fromEntries(
3333
const recommendedRules = {
3434
'jest/expect-expect': 'warn',
3535
'jest/no-alias-methods': 'error',
36+
'jest/no-async-wrapper-for-expected-promise': 'error',
3637
'jest/no-commented-out-tests': 'warn',
3738
'jest/no-conditional-expect': 'error',
3839
'jest/no-deprecated-functions': 'error',
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import dedent from 'dedent';
2+
import rule from '../no-async-wrapper-for-expected-promise';
3+
import { FlatCompatRuleTester as RuleTester, espreeParser } from './test-utils';
4+
5+
const ruleTester = new RuleTester({
6+
parser: espreeParser,
7+
parserOptions: {
8+
ecmaVersion: 2017,
9+
},
10+
});
11+
12+
ruleTester.run('no-async-wrapper-for-expected-promise', rule, {
13+
valid: [
14+
'expect.hasAssertions()',
15+
dedent`
16+
it('pass', async () => {
17+
expect();
18+
})
19+
`,
20+
dedent`
21+
it('pass', async () => {
22+
await expect(doSomethingAsync()).rejects.toThrow();
23+
})
24+
`,
25+
dedent`
26+
it('pass', async () => {
27+
await expect(doSomethingAsync(1, 2)).resolves.toBe(1);
28+
})
29+
`,
30+
dedent`
31+
it('pass', async () => {
32+
await expect(async () => {
33+
await doSomethingAsync();
34+
await doSomethingTwiceAsync(1, 2);
35+
}).rejects.toThrow();
36+
})
37+
`,
38+
{
39+
code: dedent`
40+
import { expect as pleaseExpect } from '@jest/globals';
41+
it('pass', async () => {
42+
await pleaseExpect(doSomethingAsync()).rejects.toThrow();
43+
})
44+
`,
45+
parserOptions: { sourceType: 'module' },
46+
},
47+
dedent`
48+
it('pass', async () => {
49+
await expect(async () => {
50+
doSomethingSync();
51+
}).rejects.toThrow();
52+
})
53+
`,
54+
],
55+
invalid: [
56+
{
57+
code: dedent`
58+
it('should be fix', async () => {
59+
await expect(async () => {
60+
await doSomethingAsync();
61+
}).rejects.toThrow();
62+
})
63+
`,
64+
output: dedent`
65+
it('should be fix', async () => {
66+
await expect(doSomethingAsync()).rejects.toThrow();
67+
})
68+
`,
69+
errors: [
70+
{
71+
endColumn: 6,
72+
column: 18,
73+
messageId: 'noAsyncWrapperForExpectedPromise',
74+
},
75+
],
76+
},
77+
{
78+
code: dedent`
79+
it('should be fix', async () => {
80+
await expect(async function () {
81+
await doSomethingAsync();
82+
}).rejects.toThrow();
83+
})
84+
`,
85+
output: dedent`
86+
it('should be fix', async () => {
87+
await expect(doSomethingAsync()).rejects.toThrow();
88+
})
89+
`,
90+
errors: [
91+
{
92+
endColumn: 6,
93+
column: 18,
94+
messageId: 'noAsyncWrapperForExpectedPromise',
95+
},
96+
],
97+
},
98+
{
99+
code: dedent`
100+
it('should be fix', async () => {
101+
await expect(async () => {
102+
await doSomethingAsync(1, 2);
103+
}).rejects.toThrow();
104+
})
105+
`,
106+
output: dedent`
107+
it('should be fix', async () => {
108+
await expect(doSomethingAsync(1, 2)).rejects.toThrow();
109+
})
110+
`,
111+
errors: [
112+
{
113+
endColumn: 6,
114+
column: 18,
115+
messageId: 'noAsyncWrapperForExpectedPromise',
116+
},
117+
],
118+
},
119+
{
120+
code: dedent`
121+
it('should be fix', async () => {
122+
await expect(async function () {
123+
await doSomethingAsync(1, 2);
124+
}).rejects.toThrow();
125+
})
126+
`,
127+
output: dedent`
128+
it('should be fix', async () => {
129+
await expect(doSomethingAsync(1, 2)).rejects.toThrow();
130+
})
131+
`,
132+
errors: [
133+
{
134+
endColumn: 6,
135+
column: 18,
136+
messageId: 'noAsyncWrapperForExpectedPromise',
137+
},
138+
],
139+
},
140+
],
141+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
2+
import { createRule, parseJestFnCall } from './utils';
3+
4+
export default createRule({
5+
name: __filename,
6+
meta: {
7+
docs: {
8+
description:
9+
'Disallow unnecessary async function wrapper for expected promises',
10+
},
11+
fixable: 'code',
12+
messages: {
13+
noAsyncWrapperForExpectedPromise:
14+
'Rejected/resolved promises should not be wrapped in async function',
15+
},
16+
schema: [],
17+
type: 'suggestion',
18+
},
19+
defaultOptions: [],
20+
create(context) {
21+
return {
22+
CallExpression(node: TSESTree.CallExpression) {
23+
const jestFnCall = parseJestFnCall(node, context);
24+
25+
if (jestFnCall?.type !== 'expect') {
26+
return;
27+
}
28+
29+
const { parent } = jestFnCall.head.node;
30+
31+
if (parent?.type !== AST_NODE_TYPES.CallExpression) {
32+
return;
33+
}
34+
35+
const [awaitNode] = parent.arguments;
36+
37+
if (
38+
(awaitNode?.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
39+
awaitNode?.type !== AST_NODE_TYPES.FunctionExpression) ||
40+
!awaitNode?.async ||
41+
awaitNode.body.type !== AST_NODE_TYPES.BlockStatement ||
42+
awaitNode.body.body.length !== 1
43+
) {
44+
return;
45+
}
46+
47+
const [callback] = awaitNode.body.body;
48+
49+
if (
50+
callback.type === AST_NODE_TYPES.ExpressionStatement &&
51+
callback.expression.type === AST_NODE_TYPES.AwaitExpression &&
52+
callback.expression.argument.type === AST_NODE_TYPES.CallExpression
53+
) {
54+
const innerAsyncFuncCall = callback.expression.argument;
55+
56+
context.report({
57+
node: awaitNode,
58+
messageId: 'noAsyncWrapperForExpectedPromise',
59+
fix(fixer) {
60+
const { sourceCode } = context;
61+
62+
return [
63+
fixer.replaceTextRange(
64+
[awaitNode.range[0], awaitNode.range[1]],
65+
sourceCode.getText(innerAsyncFuncCall),
66+
),
67+
];
68+
},
69+
});
70+
}
71+
},
72+
};
73+
},
74+
});

0 commit comments

Comments
 (0)