Skip to content

Commit 074579a

Browse files
committed
feat(prefer-presence-queries): use aggressive query reporting
1 parent 756c688 commit 074579a

File tree

6 files changed

+101
-79
lines changed

6 files changed

+101
-79
lines changed

.lintstagedrc

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"*.{js,ts}": [
3-
"eslint --fix",
3+
"eslint --max-warnings 0 --fix",
44
"prettier --write",
55
"jest --findRelatedTests"
66
],

lib/detect-testing-library-utils.ts

+30-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';
2-
import { getImportModuleName, isLiteral, ImportModuleNode } from './node-utils';
2+
import { getImportModuleName, ImportModuleNode, isLiteral } from './node-utils';
33

44
export type TestingLibrarySettings = {
55
'testing-library/module'?: string;
@@ -32,6 +32,9 @@ export type DetectionHelpers = {
3232
getCustomModuleImportName: () => string | undefined;
3333
getIsTestingLibraryImported: () => boolean;
3434
getIsValidFilename: () => boolean;
35+
isGetByQuery: (node: TSESTree.Identifier) => boolean;
36+
isQueryByQuery: (node: TSESTree.Identifier) => boolean;
37+
isSyncQuery: (node: TSESTree.Identifier) => boolean;
3538
canReportErrors: () => boolean;
3639
};
3740

@@ -73,7 +76,8 @@ export function detectTestingLibraryUtils<
7376
return getImportModuleName(importedCustomModuleNode);
7477
},
7578
/**
76-
* Gets if Testing Library is considered as imported or not.
79+
* Determines whether Testing Library utils are imported or not for
80+
* current file being analyzed.
7781
*
7882
* By default, it is ALWAYS considered as imported. This is what we call
7983
* "aggressive reporting" so we don't miss TL utils reexported from
@@ -93,17 +97,37 @@ export function detectTestingLibraryUtils<
9397
},
9498

9599
/**
96-
* Gets if filename being analyzed is valid or not.
97-
*
98-
* This is based on "testing-library/filename-pattern" setting.
100+
* Determines whether filename is valid or not for current file
101+
* being analyzed based on "testing-library/filename-pattern" setting.
99102
*/
100103
getIsValidFilename() {
101104
const fileName = context.getFilename();
102105
return !!fileName.match(filenamePattern);
103106
},
104107

105108
/**
106-
* Wraps all conditions that must be met to report rules.
109+
* Determines whether a given node is `getBy*` or `getAllBy*` query variant or not.
110+
*/
111+
isGetByQuery(node) {
112+
return !!node.name.match(/^get(All)?By.+$/);
113+
},
114+
115+
/**
116+
* Determines whether a given node is `queryBy*` or `queryAllBy*` query variant or not.
117+
*/
118+
isQueryByQuery(node) {
119+
return !!node.name.match(/^query(All)?By.+$/);
120+
},
121+
122+
/**
123+
* Determines whether a given node is sync query.
124+
*/
125+
isSyncQuery(node) {
126+
return this.isGetByQuery(node) || this.isQueryByQuery(node);
127+
},
128+
129+
/**
130+
* Determines if file inspected meets all conditions to be reported by rules or not.
107131
*/
108132
canReportErrors() {
109133
return this.getIsTestingLibraryImported() && this.getIsValidFilename();

lib/rules/prefer-presence-queries.ts

+42-54
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,12 @@
1-
import { TSESTree } from '@typescript-eslint/experimental-utils';
2-
import {
3-
ABSENCE_MATCHERS,
4-
ALL_QUERIES_METHODS,
5-
PRESENCE_MATCHERS,
6-
} from '../utils';
7-
import {
8-
findClosestCallNode,
9-
isIdentifier,
10-
isMemberExpression,
11-
} from '../node-utils';
1+
import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils';
2+
import { ABSENCE_MATCHERS, PRESENCE_MATCHERS } from '../utils';
3+
import { findClosestCallNode, isMemberExpression } from '../node-utils';
124
import { createTestingLibraryRule } from '../create-testing-library-rule';
135

146
export const RULE_NAME = 'prefer-presence-queries';
157
export type MessageIds = 'presenceQuery' | 'absenceQuery';
168
type Options = [];
179

18-
const QUERIES_REGEXP = new RegExp(
19-
`^(get|query)(All)?(${ALL_QUERIES_METHODS.join('|')})$`
20-
);
21-
22-
function isThrowingQuery(node: TSESTree.Identifier) {
23-
return node.name.startsWith('get');
24-
}
25-
2610
export default createTestingLibraryRule<Options, MessageIds>({
2711
name: RULE_NAME,
2812
meta: {
@@ -44,49 +28,53 @@ export default createTestingLibraryRule<Options, MessageIds>({
4428
},
4529
defaultOptions: [],
4630

47-
create(context) {
31+
create(context, _, helpers) {
4832
return {
49-
[`CallExpression Identifier[name=${QUERIES_REGEXP}]`](
50-
node: TSESTree.Identifier
51-
) {
33+
'CallExpression Identifier'(node: TSESTree.Identifier) {
5234
const expectCallNode = findClosestCallNode(node, 'expect');
5335

54-
if (expectCallNode && isMemberExpression(expectCallNode.parent)) {
55-
const expectStatement = expectCallNode.parent;
56-
const property = expectStatement.property as TSESTree.Identifier;
57-
let matcher = property.name;
58-
let isNegatedMatcher = false;
36+
if (!expectCallNode || !isMemberExpression(expectCallNode.parent)) {
37+
return;
38+
}
5939

60-
if (
61-
matcher === 'not' &&
62-
isMemberExpression(expectStatement.parent) &&
63-
isIdentifier(expectStatement.parent.property)
64-
) {
65-
isNegatedMatcher = true;
66-
matcher = expectStatement.parent.property.name;
67-
}
40+
if (!helpers.isSyncQuery(node)) {
41+
return;
42+
}
43+
44+
const isPresenceQuery = helpers.isGetByQuery(node);
45+
const expectStatement = expectCallNode.parent;
46+
let matcher =
47+
ASTUtils.isIdentifier(expectStatement.property) &&
48+
expectStatement.property.name;
49+
let isNegatedMatcher = false;
50+
51+
if (
52+
matcher === 'not' &&
53+
isMemberExpression(expectStatement.parent) &&
54+
ASTUtils.isIdentifier(expectStatement.parent.property)
55+
) {
56+
isNegatedMatcher = true;
57+
matcher = expectStatement.parent.property.name;
58+
}
6859

69-
const validMatchers = isThrowingQuery(node)
70-
? PRESENCE_MATCHERS
71-
: ABSENCE_MATCHERS;
60+
const validMatchers = isPresenceQuery
61+
? PRESENCE_MATCHERS
62+
: ABSENCE_MATCHERS;
7263

73-
const invalidMatchers = isThrowingQuery(node)
74-
? ABSENCE_MATCHERS
75-
: PRESENCE_MATCHERS;
64+
const invalidMatchers = isPresenceQuery
65+
? ABSENCE_MATCHERS
66+
: PRESENCE_MATCHERS;
7667

77-
const messageId = isThrowingQuery(node)
78-
? 'absenceQuery'
79-
: 'presenceQuery';
68+
const messageId = isPresenceQuery ? 'absenceQuery' : 'presenceQuery';
8069

81-
if (
82-
(!isNegatedMatcher && invalidMatchers.includes(matcher)) ||
83-
(isNegatedMatcher && validMatchers.includes(matcher))
84-
) {
85-
return context.report({
86-
node,
87-
messageId,
88-
});
89-
}
70+
if (
71+
(!isNegatedMatcher && invalidMatchers.includes(matcher)) ||
72+
(isNegatedMatcher && validMatchers.includes(matcher))
73+
) {
74+
context.report({
75+
node,
76+
messageId,
77+
});
9078
}
9179
},
9280
};

lib/utils.ts

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const LIBRARY_MODULES = [
2424
'@testing-library/svelte',
2525
];
2626

27+
// TODO: should be deleted after all rules are migrated to v4
2728
const hasTestingLibraryImportModule = (
2829
node: TSESTree.ImportDeclaration
2930
): boolean => {

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"scripts": {
2828
"build": "tsc",
2929
"postbuild": "cpy README.md ./dist && cpy package.json ./dist && cpy LICENSE ./dist",
30-
"lint": "eslint . --ext .js,.ts",
30+
"lint": "eslint . --max-warnings 0 --ext .js,.ts",
3131
"lint:fix": "npm run lint -- --fix",
3232
"format": "prettier --write README.md \"{lib,docs,tests}/**/*.{js,ts,md}\"",
3333
"format:check": "prettier --check README.md \"{lib,docs,tests}/**/*.{js,json,yml,ts,md}\"",

tests/lib/rules/prefer-presence-queries.test.ts

+26-17
Original file line numberDiff line numberDiff line change
@@ -346,29 +346,25 @@ ruleTester.run(RULE_NAME, rule, {
346346
{
347347
code: 'const el = queryByText("button")',
348348
},
349-
{
350-
// TODO: this one is gonna be reported by aggressive query reporting
351-
code:
352-
'expect(getByNonTestingLibraryQuery("button")).not.toBeInTheDocument()',
353-
},
354-
{
355-
// TODO: this one is gonna be reported by aggressive query reporting
356-
code:
357-
'expect(queryByNonTestingLibraryQuery("button")).toBeInTheDocument()',
358-
},
359349
{
360350
code: `async () => {
361351
const el = await findByText('button')
362352
expect(el).toBeInTheDocument()
363353
}`,
364354
},
355+
`// case: query an element with getBy but then check its absence after doing
356+
// some action which makes it disappear.
357+
358+
// submit button exists
359+
const submitButton = screen.getByRole('button')
360+
fireEvent.click(submitButton)
361+
362+
// right after clicking submit button it disappears
363+
expect(submitButton).not.toBeInTheDocument()
364+
`,
365365
// some weird examples after here to check guard against parent nodes
366-
{
367-
code: 'expect(getByText("button")).not()',
368-
},
369-
{
370-
code: 'expect(queryByText("button")).not()',
371-
},
366+
'expect(getByText("button")).not()',
367+
'expect(queryByText("button")).not()',
372368
],
373369
invalid: [
374370
// cases: asserting absence incorrectly with `getBy*` queries
@@ -655,7 +651,20 @@ ruleTester.run(RULE_NAME, rule, {
655651
code: 'expect(screen.queryAllByText("button")[1]).toBeInTheDocument()',
656652
errors: [{ messageId: 'presenceQuery', line: 1, column: 15 }],
657653
},
658-
// TODO: add more tests for using custom queries
654+
{
655+
code: `
656+
// case: asserting presence incorrectly with custom queryBy* query
657+
expect(queryByCustomQuery("button")).toBeInTheDocument()
658+
`,
659+
errors: [{ messageId: 'presenceQuery', line: 3, column: 16 }],
660+
},
661+
{
662+
code: `
663+
// case: asserting absence incorrectly with custom getBy* query
664+
expect(getByCustomQuery("button")).not.toBeInTheDocument()
665+
`,
666+
errors: [{ messageId: 'absenceQuery', line: 3, column: 16 }],
667+
},
659668
// TODO: add more tests for importing custom module
660669
],
661670
});

0 commit comments

Comments
 (0)