Skip to content

Commit b94437a

Browse files
authored
feat: new prefer-query-by-disappearance rule (#402)
* feat: report non-callback version * feat: report arrow functions with implicit return * feat: report function expression without return * feat: report function() with return violation * feat: report arrow functions with block statements * test: add more test cases * feat: report destructured getBy* queries * refactor: rename argument names * test: add findBy* tests * refactor: simplify left hand side expression parsing * refactor: simplify if statements * docs: add rule documentation * refactor: update rule description and run generate:rules-list * refactor: change .reduce() to .some() * test: add test cases with render() * test: add valid case for a separate variable * fix: spelling mistakes * refactor: mutation to const * refactor: revert 'export' change * refactor: extract Options type * refactor: add isStatementViolation helper * test: add test with callback saved in a variable Closes #399
1 parent a6e243a commit b94437a

File tree

6 files changed

+978
-1
lines changed

6 files changed

+978
-1
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ To enable this configuration use the `extends` property in your
207207
| [`testing-library/prefer-explicit-assert`](./docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | |
208208
| [`testing-library/prefer-find-by`](./docs/rules/prefer-find-by.md) | Suggest using `find(All)By*` query instead of `waitFor` + `get(All)By*` to wait for elements | 🔧 | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] |
209209
| [`testing-library/prefer-presence-queries`](./docs/rules/prefer-presence-queries.md) | Ensure appropriate `get*`/`query*` queries are used with their respective matchers | | |
210+
| [`testing-library/prefer-query-by-disappearance`](./docs/rules/prefer-query-by-disappearance.md) | Suggest using `queryBy*` queries when waiting for disappearance | | |
210211
| [`testing-library/prefer-screen-queries`](./docs/rules/prefer-screen-queries.md) | Suggest using `screen` while querying | | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] |
211212
| [`testing-library/prefer-user-event`](./docs/rules/prefer-user-event.md) | Suggest using `userEvent` over `fireEvent` for simulating user interactions | | |
212213
| [`testing-library/prefer-wait-for`](./docs/rules/prefer-wait-for.md) | Use `waitFor` instead of deprecated wait methods | 🔧 | |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Suggest using `queryBy*` queries when waiting for disappearance (`testing-library/prefer-query-by-disappearance`)
2+
3+
## Rule Details
4+
5+
This rule enforces using `queryBy*` queries when waiting for disappearance with `waitForElementToBeRemoved`.
6+
7+
Using `queryBy*` queries in a `waitForElementToBeRemoved` yields more descriptive error messages and helps to achieve more consistency in a codebase.
8+
9+
```js
10+
// TestingLibraryElementError: Unable to find an element by: [data-testid="loader"]
11+
await waitForElementToBeRemoved(screen.getByTestId('loader'));
12+
13+
// The element(s) given to waitForElementToBeRemoved are already removed.
14+
// waitForElementToBeRemoved requires that the element(s) exist(s) before waiting for removal.
15+
await waitForElementToBeRemoved(screen.queryByTestId('loader'));
16+
```
17+
18+
Example of **incorrect** code for this rule:
19+
20+
```js
21+
await waitForElementToBeRemoved(() => screen.getByText('hello'));
22+
await waitForElementToBeRemoved(() => screen.findByText('hello'));
23+
24+
await waitForElementToBeRemoved(screen.getByText('hello'));
25+
await waitForElementToBeRemoved(screen.findByText('hello'));
26+
```
27+
28+
Examples of **correct** code for this rule:
29+
30+
```js
31+
await waitForElementToBeRemoved(() => screen.queryByText('hello'));
32+
await waitForElementToBeRemoved(screen.queryByText('hello'));
33+
```

lib/node-utils/is-node-of-type.ts

+3
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,6 @@ export const isObjectExpression = isNodeOfType(AST_NODE_TYPES.ObjectExpression);
4646
export const isObjectPattern = isNodeOfType(AST_NODE_TYPES.ObjectPattern);
4747
export const isProperty = isNodeOfType(AST_NODE_TYPES.Property);
4848
export const isReturnStatement = isNodeOfType(AST_NODE_TYPES.ReturnStatement);
49+
export const isFunctionExpression = isNodeOfType(
50+
AST_NODE_TYPES.FunctionExpression
51+
);
+183
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { TSESTree } from '@typescript-eslint/experimental-utils';
2+
3+
import { createTestingLibraryRule } from '../create-testing-library-rule';
4+
import {
5+
getPropertyIdentifierNode,
6+
isArrowFunctionExpression,
7+
isCallExpression,
8+
isMemberExpression,
9+
isFunctionExpression,
10+
isExpressionStatement,
11+
isReturnStatement,
12+
isBlockStatement,
13+
} from '../node-utils';
14+
15+
export const RULE_NAME = 'prefer-query-wait-disappearance';
16+
type MessageIds = 'preferQueryByDisappearance';
17+
type Options = [];
18+
19+
export default createTestingLibraryRule<Options, MessageIds>({
20+
name: RULE_NAME,
21+
meta: {
22+
type: 'problem',
23+
docs: {
24+
description:
25+
'Suggest using `queryBy*` queries when waiting for disappearance',
26+
category: 'Possible Errors',
27+
recommendedConfig: {
28+
dom: false,
29+
angular: false,
30+
react: false,
31+
vue: false,
32+
},
33+
},
34+
messages: {
35+
preferQueryByDisappearance:
36+
'Prefer using queryBy* when waiting for disappearance',
37+
},
38+
schema: [],
39+
},
40+
defaultOptions: [],
41+
42+
create(context, _, helpers) {
43+
function isWaitForElementToBeRemoved(node: TSESTree.CallExpression) {
44+
const identifierNode = getPropertyIdentifierNode(node);
45+
46+
if (!identifierNode) {
47+
return false;
48+
}
49+
50+
return helpers.isAsyncUtil(identifierNode, ['waitForElementToBeRemoved']);
51+
}
52+
53+
function isReportableExpression(node: TSESTree.LeftHandSideExpression) {
54+
const argumentProperty = isMemberExpression(node)
55+
? getPropertyIdentifierNode(node.property)
56+
: getPropertyIdentifierNode(node);
57+
58+
if (!argumentProperty) {
59+
return false;
60+
}
61+
62+
return (
63+
helpers.isGetQueryVariant(argumentProperty) ||
64+
helpers.isFindQueryVariant(argumentProperty)
65+
);
66+
}
67+
68+
function isNonCallbackViolation(node: TSESTree.CallExpressionArgument) {
69+
if (!isCallExpression(node)) {
70+
return false;
71+
}
72+
73+
if (
74+
!isMemberExpression(node.callee) &&
75+
!getPropertyIdentifierNode(node.callee)
76+
) {
77+
return false;
78+
}
79+
80+
return isReportableExpression(node.callee);
81+
}
82+
83+
function isReturnViolation(node: TSESTree.Statement) {
84+
if (!isReturnStatement(node) || !isCallExpression(node.argument)) {
85+
return false;
86+
}
87+
88+
return isReportableExpression(node.argument.callee);
89+
}
90+
91+
function isNonReturnViolation(node: TSESTree.Statement) {
92+
if (!isExpressionStatement(node) || !isCallExpression(node.expression)) {
93+
return false;
94+
}
95+
96+
if (
97+
!isMemberExpression(node.expression.callee) &&
98+
!getPropertyIdentifierNode(node.expression.callee)
99+
) {
100+
return false;
101+
}
102+
103+
return isReportableExpression(node.expression.callee);
104+
}
105+
106+
function isStatementViolation(statement: TSESTree.Statement) {
107+
return isReturnViolation(statement) || isNonReturnViolation(statement);
108+
}
109+
110+
function isFunctionExpressionViolation(
111+
node: TSESTree.CallExpressionArgument
112+
) {
113+
if (!isFunctionExpression(node)) {
114+
return false;
115+
}
116+
117+
return node.body.body.some((statement) =>
118+
isStatementViolation(statement)
119+
);
120+
}
121+
122+
function isArrowFunctionBodyViolation(
123+
node: TSESTree.CallExpressionArgument
124+
) {
125+
if (!isArrowFunctionExpression(node) || !isBlockStatement(node.body)) {
126+
return false;
127+
}
128+
129+
return node.body.body.some((statement) =>
130+
isStatementViolation(statement)
131+
);
132+
}
133+
134+
function isArrowFunctionImplicitReturnViolation(
135+
node: TSESTree.CallExpressionArgument
136+
) {
137+
if (!isArrowFunctionExpression(node) || !isCallExpression(node.body)) {
138+
return false;
139+
}
140+
141+
if (
142+
!isMemberExpression(node.body.callee) &&
143+
!getPropertyIdentifierNode(node.body.callee)
144+
) {
145+
return false;
146+
}
147+
148+
return isReportableExpression(node.body.callee);
149+
}
150+
151+
function isArrowFunctionViolation(node: TSESTree.CallExpressionArgument) {
152+
return (
153+
isArrowFunctionBodyViolation(node) ||
154+
isArrowFunctionImplicitReturnViolation(node)
155+
);
156+
}
157+
158+
function check(node: TSESTree.CallExpression) {
159+
if (!isWaitForElementToBeRemoved(node)) {
160+
return;
161+
}
162+
163+
const argumentNode = node.arguments[0];
164+
165+
if (
166+
!isNonCallbackViolation(argumentNode) &&
167+
!isArrowFunctionViolation(argumentNode) &&
168+
!isFunctionExpressionViolation(argumentNode)
169+
) {
170+
return;
171+
}
172+
173+
context.report({
174+
node: argumentNode,
175+
messageId: 'preferQueryByDisappearance',
176+
});
177+
}
178+
179+
return {
180+
CallExpression: check,
181+
};
182+
},
183+
});

tests/index.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import plugin from '../lib';
66

77
const generateConfigs = () => exec(`npm run generate:configs`);
88

9-
const numberOfRules = 25;
9+
const numberOfRules = 26;
1010
const ruleNames = Object.keys(plugin.rules);
1111

1212
// eslint-disable-next-line jest/expect-expect

0 commit comments

Comments
 (0)