Skip to content

Commit f12acee

Browse files
feat: added new rule prefer-in-document (#95)
Co-authored-by: Ben Monro <[email protected]>
1 parent aeb80dc commit f12acee

File tree

6 files changed

+261
-13
lines changed

6 files changed

+261
-13
lines changed

README.md

+12-12
Original file line numberDiff line numberDiff line change
@@ -100,18 +100,17 @@ module.exports = {
100100
🔧 indicates that a rule is fixable.
101101

102102
<!-- __BEGIN AUTOGENERATED TABLE__ -->
103-
104-
| Name | 👍 | 🔧 | Description |
105-
| ---------------------------------------------------------------------------------------------------------------------------------------------- | --- | --- | -------------------------------------------------------------- |
106-
| [prefer-checked](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-checked.md) | 👍 | 🔧 | prefer toBeChecked over checking attributes |
107-
| [prefer-empty](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-empty.md) | 👍 | 🔧 | Prefer toBeEmpty over checking innerHTML |
108-
| [prefer-enabled-disabled](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-enabled-disabled.md) | 👍 | 🔧 | prefer toBeDisabled or toBeEnabled over checking attributes |
109-
| [prefer-focus](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-focus.md) | 👍 | 🔧 | prefer toHaveFocus over checking document.activeElement |
110-
| [prefer-required](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-required.md) | 👍 | 🔧 | prefer toBeRequired over checking properties |
111-
| [prefer-to-have-attribute](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-to-have-attribute.md) | 👍 | 🔧 | prefer toHaveAttribute over checking getAttribute/hasAttribute |
112-
| [prefer-to-have-style](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-to-have-style.md) | 👍 | 🔧 | prefer toHaveStyle over checking element style |
113-
| [prefer-to-have-text-content](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-to-have-text-content.md) | 👍 | 🔧 | Prefer toHaveTextContent over checking element.textContent |
114-
103+
Name | 👍 | 🔧 | Description
104+
----- | ----- | ----- | -----
105+
[prefer-checked](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-checked.md) | 👍 | 🔧 | prefer toBeChecked over checking attributes
106+
[prefer-empty](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-empty.md) | 👍 | 🔧 | Prefer toBeEmpty over checking innerHTML
107+
[prefer-enabled-disabled](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-enabled-disabled.md) | 👍 | 🔧 | prefer toBeDisabled or toBeEnabled over checking attributes
108+
[prefer-focus](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-focus.md) | 👍 | 🔧 | prefer toHaveFocus over checking document.activeElement
109+
[prefer-in-document](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-in-document.md) | | 🔧 | Prefer .toBeInTheDocument() in favor of checking the length of the result using .toHaveLength(1)
110+
[prefer-required](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-required.md) | 👍 | 🔧 | prefer toBeRequired over checking properties
111+
[prefer-to-have-attribute](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-to-have-attribute.md) | 👍 | 🔧 | prefer toHaveAttribute over checking getAttribute/hasAttribute
112+
[prefer-to-have-style](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-to-have-style.md) | 👍 | 🔧 | prefer toHaveStyle over checking element style
113+
[prefer-to-have-text-content](https://github.com/testing-library/eslint-plugin-jest-dom/blob/master/docs/rules/prefer-to-have-text-content.md) | 👍 | 🔧 | Prefer toHaveTextContent over checking element.textContent
115114
<!-- __END AUTOGENERATED TABLE__ -->
116115

117116
## Issues
@@ -160,6 +159,7 @@ Thanks goes to these people ([emoji key][emojis]):
160159

161160
<!-- markdownlint-enable -->
162161
<!-- prettier-ignore-end -->
162+
163163
<!-- ALL-CONTRIBUTORS-LIST:END -->
164164

165165
This project follows the [all-contributors][all-contributors] specification.

docs/rules/prefer-in-document.md

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Prefer .toBeInTheDocument in favor of .toHaveLength(1) (prefer-in-document)
2+
3+
## Rule Details
4+
5+
This rule enforces checking existance of DOM nodes using `.toBeInTheDocument()`.
6+
The rule prefers that matcher over various existance checks such as `.toHaveLength(1)`, `.not.toBeNull()` and
7+
similar.
8+
9+
Examples of **incorrect** code for this rule:
10+
11+
```js
12+
expect(screen.queryByText("foo")).toHaveLength(1);
13+
expect(queryByText("foo")).toHaveLength(1);
14+
expect(wrapper.queryByText("foo")).toHaveLength(1);
15+
expect(queryByText("foo")).toHaveLength(0);
16+
expect(queryByText("foo")).toBeNull();
17+
expect(queryByText("foo")).not.toBeNull();
18+
expect(queryByText("foo")).toBeDefined();
19+
expect(queryByText("foo")).not.toBeDefined();
20+
```
21+
22+
Examples of **correct** code for this rule:
23+
24+
```js
25+
expect(screen.queryByText("foo")).toBeInTheDocument();
26+
expect(screen.queryByText("foo")).toBeInTheDocument();
27+
expect(queryByText("foo")).toBeInTheDocument()`;
28+
expect(wrapper.queryAllByTestId('foo')).toBeInTheDocument()`;
29+
expect(screen.getAllByLabel("foo-bar")).toHaveLength(2)`;
30+
expect(notAQuery('foo-bar')).toHaveLength(1)`;
31+
```
32+
33+
## When Not To Use It
34+
35+
Don't use this rule if you don't care about the added readability and
36+
improvements that `toBeInTheDocument` offers to your expects.
37+
38+
## Further Reading
39+
40+
- [Docs on toBeInTheDocument](https://github.com/testing-library/jest-dom#tobeinthedocument)

package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
},
4040
"dependencies": {
4141
"@babel/runtime": "^7.9.6",
42+
"@testing-library/dom": "^7.28.1",
4243
"requireindex": "^1.2.0"
4344
},
4445
"devDependencies": {
@@ -53,7 +54,8 @@
5354
"extends": "./node_modules/kcd-scripts/eslint.js",
5455
"rules": {
5556
"babel/quotes": "off",
56-
"max-lines-per-function": "off"
57+
"max-lines-per-function": "off",
58+
"testing-library/no-dom-import": "off"
5759
}
5860
},
5961
"eslintIgnore": [
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @fileoverview Prefer toBeInTheDocument over querying and asserting length.
3+
* @author Anton Niklasson
4+
*/
5+
6+
//------------------------------------------------------------------------------
7+
// Requirements
8+
//------------------------------------------------------------------------------
9+
10+
import { RuleTester } from "eslint";
11+
import { queries, queriesByVariant } from "../../../queries";
12+
import * as rule from "../../../rules/prefer-in-document";
13+
14+
//------------------------------------------------------------------------------
15+
// Tests
16+
//------------------------------------------------------------------------------
17+
18+
function invalidCase(code, output) {
19+
return {
20+
code,
21+
output,
22+
errors: [
23+
{
24+
messageId: "use-document",
25+
},
26+
],
27+
};
28+
}
29+
30+
const valid = [
31+
...queries.map((q) => [
32+
`expect(screen.${q}('foo')).toBeInTheDocument()`,
33+
`expect(${q}('foo')).toBeInTheDocument()`,
34+
`expect(wrapper.${q}('foo')).toBeInTheDocument()`,
35+
]),
36+
`expect(screen.notAQuery('foo-bar')).toHaveLength(1)`,
37+
`expect(screen.getByText('foo-bar')).toHaveLength(2)`,
38+
];
39+
const invalid = [
40+
// Invalid cases that applies to all variants
41+
...queries.map((q) => [
42+
invalidCase(
43+
`expect(screen.${q}('foo')).toHaveLength(1)`,
44+
`expect(screen.${q}('foo')).toBeInTheDocument()`
45+
),
46+
invalidCase(
47+
`expect(${q}('foo')).toHaveLength(1)`,
48+
`expect(${q}('foo')).toBeInTheDocument()`
49+
),
50+
invalidCase(
51+
`expect(wrapper.${q}('foo')).toHaveLength(1)`,
52+
`expect(wrapper.${q}('foo')).toBeInTheDocument()`
53+
),
54+
]),
55+
// Invalid cases that applies to queryBy* and queryAllBy*
56+
...queriesByVariant.query.map((q) => [
57+
invalidCase(
58+
`expect(${q}('foo')).toHaveLength(0)`,
59+
`expect(${q}('foo')).not.toBeInTheDocument()`
60+
),
61+
invalidCase(
62+
`expect(${q}('foo')).toBeNull()`,
63+
`expect(${q}('foo')).not.toBeInTheDocument()`
64+
),
65+
invalidCase(
66+
`expect(${q}('foo')).not.toBeNull()`,
67+
`expect(${q}('foo')).toBeInTheDocument()`
68+
),
69+
invalidCase(
70+
`expect(${q}('foo')).toBeDefined()`,
71+
`expect(${q}('foo')).toBeInTheDocument()`
72+
),
73+
invalidCase(
74+
`expect(${q}('foo')).not.toBeDefined()`,
75+
`expect(${q}('foo')).not.toBeInTheDocument()`
76+
),
77+
]),
78+
];
79+
80+
const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 2015 } });
81+
ruleTester.run("prefer-in-document", rule, {
82+
valid: [].concat(...valid),
83+
invalid: [].concat(...invalid),
84+
});

src/queries.js

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { queries as allQueries } from "@testing-library/dom";
2+
3+
export const queries = Object.keys(allQueries);
4+
5+
export const queriesByVariant = {
6+
query: queries.filter((q) => q.startsWith("query")),
7+
get: queries.filter((q) => q.startsWith("get")),
8+
find: queries.filter((q) => q.startsWith("find")),
9+
};

src/rules/prefer-in-document.js

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* @fileoverview prefer toBeInTheDocument over checking getAttribute/hasAttribute
3+
* @author Anton Niklasson
4+
*/
5+
6+
import { queries } from "../queries";
7+
8+
export const meta = {
9+
type: "suggestion",
10+
docs: {
11+
category: "jest-dom",
12+
description:
13+
"Prefer .toBeInTheDocument() for asserting the existence of a DOM node",
14+
url: "prefer-in-document",
15+
recommended: false,
16+
},
17+
fixable: "code",
18+
messages: {
19+
"use-document": `Prefer .toBeInTheDocument() for asserting DOM node existence`,
20+
},
21+
};
22+
23+
function isAntonymMatcher(matcherNode, matcherArguments) {
24+
return (
25+
matcherNode.name === "toBeNull" ||
26+
(matcherNode.name === "toHaveLength" && matcherArguments[0].value === 0)
27+
);
28+
}
29+
30+
function check(
31+
context,
32+
{ queryNode, matcherNode, matcherArguments, negatedMatcher }
33+
) {
34+
const query = queryNode.name || queryNode.property.name;
35+
36+
// toHaveLength() is only invalid with 0 or 1
37+
if (matcherNode.name === "toHaveLength" && matcherArguments[0].value > 1) {
38+
return;
39+
}
40+
41+
if (queries.includes(query)) {
42+
context.report({
43+
node: matcherNode,
44+
messageId: "use-document",
45+
loc: matcherNode.loc,
46+
fix(fixer) {
47+
const operations = [];
48+
49+
// Flip the .not if neccessary
50+
if (isAntonymMatcher(matcherNode, matcherArguments)) {
51+
if (negatedMatcher) {
52+
operations.push(
53+
fixer.removeRange([
54+
matcherNode.range[0] - 5,
55+
matcherNode.range[0] - 1,
56+
])
57+
);
58+
} else {
59+
operations.push(fixer.insertTextBefore(matcherNode, "not."));
60+
}
61+
}
62+
63+
// Replace the actual matcher
64+
operations.push(fixer.replaceText(matcherNode, "toBeInTheDocument"));
65+
66+
// Remove any arguments in the matcher
67+
for (const argument of matcherArguments) {
68+
operations.push(fixer.remove(argument));
69+
}
70+
71+
return operations;
72+
},
73+
});
74+
}
75+
}
76+
77+
export const create = (context) => {
78+
const alternativeMatchers = /(toHaveLength|toBeDefined|toBeNull)/;
79+
80+
return {
81+
// Grabbing expect(<query>).not.<matcher>
82+
[`CallExpression[callee.object.object.callee.name='expect'][callee.object.property.name='not'][callee.property.name=${alternativeMatchers}]`](
83+
node
84+
) {
85+
const queryNode = node.callee.object.object.arguments[0].callee;
86+
const matcherNode = node.callee.property;
87+
const matcherArguments = node.arguments;
88+
89+
check(context, {
90+
negatedMatcher: true,
91+
queryNode,
92+
matcherNode,
93+
matcherArguments,
94+
});
95+
},
96+
97+
// Grabbing expect(<query>).<matcher>
98+
[`CallExpression[callee.object.callee.name='expect'][callee.property.name=${alternativeMatchers}]`](
99+
node
100+
) {
101+
const queryNode = node.callee.object.arguments[0].callee;
102+
const matcherNode = node.callee.property;
103+
const matcherArguments = node.arguments;
104+
105+
check(context, {
106+
negatedMatcher: false,
107+
queryNode,
108+
matcherNode,
109+
matcherArguments,
110+
});
111+
},
112+
};
113+
};

0 commit comments

Comments
 (0)