Skip to content

Commit a912e68

Browse files
authored
fix(rules): added more use cases for prefer-in-document (#121)
* fix(rules): more use cases for prefer-in-document * added notes about .length * covg * fix cwd issue in husky on CI * fix cwd issue in husky on CI * fix cwd issue in husky on CI
1 parent 5c8c9aa commit a912e68

File tree

4 files changed

+172
-77
lines changed

4 files changed

+172
-77
lines changed

.github/workflows/eslint-plugin-jest-dom.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
with:
2020
node-version: ${{ matrix.node }}
2121
- uses: actions/checkout@v1
22-
- run: npm install
22+
- run: INIT_CWD="$(pwd)" npm install
2323
- run: npm run validate
2424
- run: npx codecov@3
2525
release:

docs/rules/prefer-in-document.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ expect(bar).toHaveLength(0);
2828
Examples of **correct** code for this rule:
2929

3030
```js
31+
expect(screen.getByText("foo").length).toBe(1);
3132
expect(screen.queryByText("foo")).toBeInTheDocument();
3233
expect(await screen.findByText("foo")).toBeInTheDocument();
3334
expect(queryByText("foo")).toBeInTheDocument();
@@ -50,6 +51,11 @@ expect(baz).toBeDefined();
5051
Don't use this rule if you don't care about the added readability and
5152
improvements that `toBeInTheDocument` offers to your expects.
5253

54+
Note, that `expect(screen.getByText("foo").length).toBe(1)` is valid. If you want to report on that please use [jest/prefer-to-have-length](https://github.com/jest-community/eslint-plugin-jest/blob/HEAD/docs/rules/prefer-to-have-length.md)
55+
![fixable][] which will first convert those use cases to use `.toHaveLength`.
56+
5357
## Further Reading
5458

5559
- [Docs on toBeInTheDocument](https://github.com/testing-library/jest-dom#tobeinthedocument)
60+
61+
[fixable]: https://img.shields.io/badge/-fixable-green.svg

src/__tests__/lib/rules/prefer-in-document.js

Lines changed: 105 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,28 @@ const valid = [
7474
`expect(await screen.findAllByRole("button")).not.toHaveLength(
7575
NUM_BUTTONS
7676
)`,
77+
`expect( screen.getAllByRole("link") ).not.toHaveLength(0);`,
78+
79+
`import {NUM_BUTTONS} from "./foo";
80+
expect(screen.getByText('foo')).toHaveLength(NUM_BUTTONS)`,
81+
`expect(screen.getAllByText("foo")).toHaveLength(getLength())`,
7782
];
7883
const invalid = [
79-
// Invalid cases that applies to all variants
80-
8184
invalidCase(
8285
`expect(screen.getByText('foo')).toHaveLength(1)`,
8386
`expect(screen.getByText('foo')).toBeInTheDocument()`
8487
),
88+
invalidCase(
89+
`const NUM_BUTTONS=1;
90+
expect(screen.getByText('foo')).toHaveLength(NUM_BUTTONS)`,
91+
`const NUM_BUTTONS=1;
92+
expect(screen.getByText('foo')).toBeInTheDocument()`
93+
),
94+
95+
invalidCase(
96+
`expect(screen.queryAllByTestId("foo")).toHaveLength(0)`,
97+
`expect(screen.queryByTestId("foo")).not.toBeInTheDocument()`
98+
),
8599
invalidCase(
86100
`expect(getByText('foo')).toHaveLength(1)`,
87101
`expect(getByText('foo')).toBeInTheDocument()`
@@ -205,54 +219,101 @@ const invalid = [
205219
),
206220

207221
// Invalid cases that applies to queryBy* and queryAllBy*
208-
...["queryByText", "queryAllByText"].map((q) => [
209-
invalidCase(
210-
`expect(${q}('foo')).toHaveLength(0)`,
211-
`expect(${q}('foo')).not.toBeInTheDocument()`
212-
),
213-
invalidCase(
214-
`expect(${q}('foo')).toBeNull()`,
215-
`expect(${q}('foo')).not.toBeInTheDocument()`
216-
),
217-
invalidCase(
218-
`expect(${q}('foo')).not.toBeNull()`,
219-
`expect(${q}('foo')).toBeInTheDocument()`
220-
),
221-
invalidCase(
222-
`expect(${q}('foo')) .not .toBeNull()`,
223-
`expect(${q}('foo')).toBeInTheDocument()`
224-
),
225-
invalidCase(
226-
`expect(${q}('foo')).toBeDefined()`,
227-
`expect(${q}('foo')).toBeInTheDocument()`
228-
),
229-
invalidCase(
230-
`expect(${q}('foo')) .not .toBeDefined()`,
231-
`expect(${q}('foo')) .not .toBeInTheDocument()`
232-
),
233-
invalidCase(
234-
`let foo;
235-
foo = screen.${q}('foo');
222+
223+
invalidCase(
224+
`expect(queryByText('foo')).toHaveLength(0)`,
225+
`expect(queryByText('foo')).not.toBeInTheDocument()`
226+
),
227+
invalidCase(
228+
`expect(queryByText('foo')).toBeNull()`,
229+
`expect(queryByText('foo')).not.toBeInTheDocument()`
230+
),
231+
invalidCase(
232+
`expect(queryByText('foo')).not.toBeNull()`,
233+
`expect(queryByText('foo')).toBeInTheDocument()`
234+
),
235+
invalidCase(
236+
`expect(queryByText('foo')) .not .toBeNull()`,
237+
`expect(queryByText('foo')).toBeInTheDocument()`
238+
),
239+
invalidCase(
240+
`expect(queryByText('foo')).toBeDefined()`,
241+
`expect(queryByText('foo')).toBeInTheDocument()`
242+
),
243+
invalidCase(
244+
`expect(queryByText('foo')) .not .toBeDefined()`,
245+
`expect(queryByText('foo')) .not .toBeInTheDocument()`
246+
),
247+
invalidCase(
248+
`let foo;
249+
foo = screen.queryByText('foo');
236250
expect(foo).toHaveLength(0);`,
237-
`let foo;
238-
foo = screen.${q}('foo');
251+
`let foo;
252+
foo = screen.queryByText('foo');
239253
expect(foo).not.toBeInTheDocument();`
240-
),
241-
invalidCase(
242-
`let foo;
243-
foo = screen.${q}('foo');
254+
),
255+
invalidCase(
256+
`let foo;
257+
foo = screen.queryByText('foo');
244258
expect(foo) .not.toBeNull();`,
245-
`let foo;
246-
foo = screen.${q}('foo');
259+
`let foo;
260+
foo = screen.queryByText('foo');
261+
expect(foo).toBeInTheDocument();`
262+
),
263+
invalidCase(
264+
`let foo = screen.queryByText('foo');
265+
expect(foo).not.toBeNull();`,
266+
`let foo = screen.queryByText('foo');
267+
expect(foo).toBeInTheDocument();`
268+
),
269+
270+
invalidCase(
271+
`expect(queryAllByText('foo')).toHaveLength(0)`,
272+
`expect(queryByText('foo')).not.toBeInTheDocument()`
273+
),
274+
invalidCase(
275+
`expect(queryAllByText('foo')).toBeNull()`,
276+
`expect(queryByText('foo')).not.toBeInTheDocument()`
277+
),
278+
invalidCase(
279+
`expect(queryAllByText('foo')).not.toBeNull()`,
280+
`expect(queryByText('foo')).toBeInTheDocument()`
281+
),
282+
invalidCase(
283+
`expect(queryAllByText('foo')) .not .toBeNull()`,
284+
`expect(queryByText('foo')).toBeInTheDocument()`
285+
),
286+
invalidCase(
287+
`expect(queryAllByText('foo')).toBeDefined()`,
288+
`expect(queryByText('foo')).toBeInTheDocument()`
289+
),
290+
invalidCase(
291+
`expect(queryAllByText('foo')) .not .toBeDefined()`,
292+
`expect(queryByText('foo')) .not .toBeInTheDocument()`
293+
),
294+
invalidCase(
295+
`let foo;
296+
foo = screen.queryAllByText('foo');
297+
expect(foo).toHaveLength(0);`,
298+
`let foo;
299+
foo = screen.queryByText('foo');
300+
expect(foo).not.toBeInTheDocument();`
301+
),
302+
invalidCase(
303+
`let foo;
304+
foo = screen.queryAllByText('foo');
305+
expect(foo) .not.toBeNull();`,
306+
`let foo;
307+
foo = screen.queryByText('foo');
247308
expect(foo).toBeInTheDocument();`
248-
),
249-
invalidCase(
250-
`let foo = screen.${q}('foo');
309+
),
310+
invalidCase(
311+
`let foo = screen.queryAllByText('foo');
251312
expect(foo).not.toBeNull();`,
252-
`let foo = screen.${q}('foo');
313+
`let foo = screen.queryByText('foo');
253314
expect(foo).toBeInTheDocument();`
254-
),
255-
]),
315+
),
316+
//END
256317
invalidCase(
257318
`it("foo", async () => {
258319
expect(await findByRole("button")).toBeDefined();

src/rules/prefer-in-document.js

Lines changed: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,26 +23,56 @@ export const meta = {
2323
function isAntonymMatcher(matcherNode, matcherArguments) {
2424
return (
2525
matcherNode.name === "toBeNull" ||
26-
(matcherNode.name === "toHaveLength" && matcherArguments[0].value === 0)
26+
usesToHaveLengthZero(matcherNode, matcherArguments)
2727
);
2828
}
2929

30+
function usesToHaveLengthZero(matcherNode, matcherArguments) {
31+
return matcherNode.name === "toHaveLength" && matcherArguments[0].value === 0;
32+
}
33+
3034
export const create = (context) => {
3135
const alternativeMatchers = /(toHaveLength|toBeDefined|toBeNull)/;
36+
function getLengthValue(matcherArguments) {
37+
let lengthValue;
38+
39+
if (matcherArguments[0].type === "Identifier") {
40+
const assignment = getAssignmentForIdentifier(matcherArguments[0].name);
41+
if (!assignment) {
42+
return;
43+
}
44+
lengthValue = assignment.value;
45+
} else if (matcherArguments[0].type === "Literal") {
46+
lengthValue = matcherArguments[0].value;
47+
}
48+
49+
return lengthValue;
50+
}
3251
function check({
3352
queryNode,
3453
matcherNode,
3554
matcherArguments,
3655
negatedMatcher,
3756
expect,
3857
}) {
58+
// only report on dom nodes which we can resolve to RTL queries.
3959
if (!queryNode || (!queryNode.name && !queryNode.property)) return;
60+
4061
// toHaveLength() is only invalid with 0 or 1
41-
if (
42-
matcherNode.name === "toHaveLength" &&
43-
(matcherArguments[0].type !== "Literal" || matcherArguments[0].value > 1)
44-
) {
45-
return;
62+
if (matcherNode.name === "toHaveLength") {
63+
const lengthValue = getLengthValue(matcherArguments);
64+
// isNotToHaveLengthZero represents .not.toHaveLength(0) which is a valid use of toHaveLength
65+
const isNotToHaveLengthZero =
66+
usesToHaveLengthZero(matcherNode, matcherArguments) && negatedMatcher;
67+
const isValidUseOfToHaveLength =
68+
isNotToHaveLengthZero ||
69+
!["Literal", "Identifier"].includes(matcherArguments[0].type) ||
70+
lengthValue === undefined ||
71+
lengthValue > 1;
72+
73+
if (isValidUseOfToHaveLength) {
74+
return;
75+
}
4676
}
4777

4878
const query = queryNode.name || queryNode.property.name;
@@ -59,18 +89,14 @@ export const create = (context) => {
5989
for (const argument of Array.from(matcherArguments)) {
6090
operations.push(fixer.remove(argument));
6191
}
62-
if (
63-
matcherNode.name === "toHaveLength" &&
64-
matcherArguments[0].value === 1 &&
65-
query.indexOf("All") > 0
66-
) {
67-
operations.push(
68-
fixer.replaceText(
69-
queryNode.property || queryNode,
70-
query.replace("All", "")
71-
)
72-
);
73-
}
92+
93+
// AllBy should not be used with toBeInTheDocument
94+
operations.push(
95+
fixer.replaceText(
96+
queryNode.property || queryNode,
97+
query.replace("All", "")
98+
)
99+
);
74100
// Flip the .not if necessary
75101
if (isAntonymMatcher(matcherNode, matcherArguments)) {
76102
if (negatedMatcher) {
@@ -96,36 +122,38 @@ export const create = (context) => {
96122
}
97123
}
98124

99-
function getQueryNodeFrom(expression) {
125+
function getAssignmentFrom(expression) {
100126
return expression.type === "TSAsExpression"
101-
? getQueryNodeFrom(expression.expression)
127+
? getAssignmentFrom(expression.expression)
102128
: expression.type === "AwaitExpression"
103-
? getQueryNodeFrom(expression.argument)
104-
: expression.callee;
129+
? getAssignmentFrom(expression.argument)
130+
: expression.callee
131+
? expression.callee
132+
: expression;
105133
}
106134

107-
function getQueryNodeFromAssignment(identifierName) {
135+
function getAssignmentForIdentifier(identifierName) {
108136
const variable = context.getScope().set.get(identifierName);
137+
109138
if (!variable) return;
110139
const init = variable.defs[0].node.init;
111140

112-
let queryNode;
141+
let assignmentNode;
113142
if (init) {
114-
// let foo = screen.<query>();
115-
queryNode = getQueryNodeFrom(init);
143+
// let foo = bar;
144+
assignmentNode = getAssignmentFrom(init);
116145
} else {
117146
// let foo;
118-
// foo = screen.<query>();
147+
// foo = bar;
119148
const assignmentRef = variable.references
120149
.reverse()
121150
.find((ref) => !!ref.writeExpr);
122151
if (!assignmentRef) {
123152
return;
124153
}
125-
const assignment = assignmentRef.writeExpr;
126-
queryNode = getQueryNodeFrom(assignment);
154+
assignmentNode = getAssignmentFrom(assignmentRef.writeExpr);
127155
}
128-
return queryNode;
156+
return assignmentNode;
129157
}
130158
return {
131159
// expect(<query>).not.<matcher>
@@ -151,7 +179,7 @@ export const create = (context) => {
151179
[`MemberExpression[object.object.callee.name=expect][object.property.name=not][property.name=${alternativeMatchers}][object.object.arguments.0.type=Identifier]`](
152180
node
153181
) {
154-
const queryNode = getQueryNodeFromAssignment(
182+
const queryNode = getAssignmentForIdentifier(
155183
node.object.object.arguments[0].name
156184
);
157185
const matcherNode = node.property;
@@ -172,7 +200,7 @@ export const create = (context) => {
172200
[`MemberExpression[object.callee.name=expect][property.name=${alternativeMatchers}][object.arguments.0.type=Identifier]`](
173201
node
174202
) {
175-
const queryNode = getQueryNodeFromAssignment(
203+
const queryNode = getAssignmentForIdentifier(
176204
node.object.arguments[0].name
177205
);
178206
const matcherNode = node.property;

0 commit comments

Comments
 (0)