Skip to content

Commit c880f2f

Browse files
authored
refactor(prefer-user-event): use new custom rule creator (#251)
* feat: add new settings for prefer-user-event pt1 * feat: part2 of refactoring user event. improved docs * test: improved coverage for prefer-user-event. applied feedback
1 parent 287ca77 commit c880f2f

File tree

4 files changed

+190
-49
lines changed

4 files changed

+190
-49
lines changed

docs/rules/prefer-user-event.md

+8
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Examples of **incorrect** code for this rule:
1818
```ts
1919
// a method in fireEvent that has a userEvent equivalent
2020
import { fireEvent } from '@testing-library/dom';
21+
// or const { fireEvent } = require('@testing-library/dom');
2122
fireEvent.click(node);
2223

2324
// using fireEvent with an alias
@@ -26,21 +27,26 @@ fireEventAliased.click(node);
2627

2728
// using fireEvent after importing the entire library
2829
import * as dom from '@testing-library/dom';
30+
// or const dom = require(@testing-library/dom');
2931
dom.fireEvent.click(node);
3032
```
3133

3234
Examples of **correct** code for this rule:
3335

3436
```ts
3537
import userEvent from '@testing-library/user-event';
38+
// or const userEvent = require('@testing-library/user-event');
3639

3740
// any userEvent method
3841
userEvent.click();
3942

4043
// fireEvent method that does not have an alternative in userEvent
44+
import { fireEvent } from '@testing-library/dom';
45+
// or const { fireEvent } = require('@testing-library/dom');
4146
fireEvent.cut(node);
4247

4348
import * as dom from '@testing-library/dom';
49+
// or const dom = require('@testing-library/dom');
4450
dom.fireEvent.cut(node);
4551
```
4652

@@ -69,6 +75,7 @@ With this configuration example, the following use cases are considered valid
6975
```ts
7076
// using a named import
7177
import { fireEvent } from '@testing-library/dom';
78+
// or const { fireEvent } = require('@testing-library/dom');
7279
fireEvent.click(node);
7380
fireEvent.change(node, { target: { value: 'foo' } });
7481

@@ -79,6 +86,7 @@ fireEventAliased.change(node, { target: { value: 'foo' } });
7986

8087
// using fireEvent after importing the entire library
8188
import * as dom from '@testing-library/dom';
89+
// or const dom = require('@testing-library/dom');
8290
dom.fireEvent.click(node);
8391
dom.fireEvent.change(node, { target: { value: 'foo' } });
8492
```

lib/detect-testing-library-utils.ts

+53-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';
2-
import { getImportModuleName, isLiteral, ImportModuleNode } from './node-utils';
2+
import {
3+
getImportModuleName,
4+
isLiteral,
5+
ImportModuleNode,
6+
isImportDeclaration,
7+
isImportNamespaceSpecifier,
8+
isImportSpecifier,
9+
isIdentifier,
10+
isProperty,
11+
} from './node-utils';
312

413
export type TestingLibrarySettings = {
514
'testing-library/module'?: string;
@@ -33,6 +42,9 @@ export type DetectionHelpers = {
3342
getIsTestingLibraryImported: () => boolean;
3443
getIsValidFilename: () => boolean;
3544
canReportErrors: () => boolean;
45+
findImportedUtilSpecifier: (
46+
specifierName: string
47+
) => TSESTree.ImportClause | TSESTree.Identifier | undefined;
3648
};
3749

3850
const DEFAULT_FILENAME_PATTERN = '^.*\\.(test|spec)\\.[jt]sx?$';
@@ -106,7 +118,46 @@ export function detectTestingLibraryUtils<
106118
* Wraps all conditions that must be met to report rules.
107119
*/
108120
canReportErrors() {
109-
return this.getIsTestingLibraryImported() && this.getIsValidFilename();
121+
return (
122+
helpers.getIsTestingLibraryImported() && helpers.getIsValidFilename()
123+
);
124+
},
125+
/**
126+
* Gets a string and verifies if it was imported/required by our custom module node
127+
*/
128+
findImportedUtilSpecifier(specifierName: string) {
129+
const node =
130+
helpers.getCustomModuleImportNode() ??
131+
helpers.getTestingLibraryImportNode();
132+
if (!node) {
133+
return null;
134+
}
135+
if (isImportDeclaration(node)) {
136+
const namedExport = node.specifiers.find(
137+
(n) => isImportSpecifier(n) && n.imported.name === specifierName
138+
);
139+
// it is "import { foo [as alias] } from 'baz'""
140+
if (namedExport) {
141+
return namedExport;
142+
}
143+
// it could be "import * as rtl from 'baz'"
144+
return node.specifiers.find((n) => isImportNamespaceSpecifier(n));
145+
} else {
146+
const requireNode = node.parent as TSESTree.VariableDeclarator;
147+
if (isIdentifier(requireNode.id)) {
148+
// this is const rtl = require('foo')
149+
return requireNode.id;
150+
}
151+
// this should be const { something } = require('foo')
152+
const destructuring = requireNode.id as TSESTree.ObjectPattern;
153+
const property = destructuring.properties.find(
154+
(n) =>
155+
isProperty(n) &&
156+
isIdentifier(n.key) &&
157+
n.key.name === specifierName
158+
);
159+
return (property as TSESTree.Property).key as TSESTree.Identifier;
160+
}
110161
},
111162
};
112163

lib/rules/prefer-user-event.ts

+17-46
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils';
2-
import { getDocsUrl, hasTestingLibraryImportModule } from '../utils';
3-
import {
4-
isImportSpecifier,
5-
isIdentifier,
6-
isMemberExpression,
7-
} from '../node-utils';
1+
import { TSESTree } from '@typescript-eslint/experimental-utils';
2+
import { createTestingLibraryRule } from '../create-testing-library-rule';
3+
import { isIdentifier, isMemberExpression } from '../node-utils';
84

95
export const RULE_NAME = 'prefer-user-event';
106

@@ -65,7 +61,7 @@ function buildErrorMessage(fireEventMethod: string) {
6561

6662
const fireEventMappedMethods = Object.keys(MappingToUserEvent);
6763

68-
export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
64+
export default createTestingLibraryRule<Options, MessageIds>({
6965
name: RULE_NAME,
7066
meta: {
7167
type: 'suggestion',
@@ -90,59 +86,34 @@ export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
9086
},
9187
defaultOptions: [{ allowedMethods: [] }],
9288

93-
create(context, [options]) {
89+
create(context, [options], helpers) {
9490
const { allowedMethods } = options;
9591
const sourceCode = context.getSourceCode();
96-
let hasNamedImportedFireEvent = false;
97-
let hasImportedFireEvent = false;
98-
let fireEventAlias: string | undefined;
99-
let wildcardImportName: string | undefined;
10092

10193
return {
102-
// checks if import has shape:
103-
// import { fireEvent } from '@testing-library/dom';
104-
ImportDeclaration(node: TSESTree.ImportDeclaration) {
105-
if (!hasTestingLibraryImportModule(node)) {
106-
return;
107-
}
108-
const fireEventImport = node.specifiers.find(
109-
(node) =>
110-
isImportSpecifier(node) && node.imported.name === 'fireEvent'
111-
);
112-
hasNamedImportedFireEvent = !!fireEventImport;
113-
if (!hasNamedImportedFireEvent) {
114-
return;
115-
}
116-
fireEventAlias = fireEventImport.local.name;
117-
},
118-
119-
// checks if import has shape:
120-
// import * as dom from '@testing-library/dom';
121-
'ImportDeclaration ImportNamespaceSpecifier'(
122-
node: TSESTree.ImportNamespaceSpecifier
123-
) {
124-
const importDeclarationNode = node.parent as TSESTree.ImportDeclaration;
125-
if (!hasTestingLibraryImportModule(importDeclarationNode)) {
126-
return;
127-
}
128-
hasImportedFireEvent = !!node.local.name;
129-
wildcardImportName = node.local.name;
130-
},
13194
['CallExpression > MemberExpression'](node: TSESTree.MemberExpression) {
132-
if (!hasImportedFireEvent && !hasNamedImportedFireEvent) {
95+
const util = helpers.findImportedUtilSpecifier('fireEvent');
96+
if (!util) {
97+
// testing library was imported, but fireEvent was not imported
13398
return;
13499
}
135-
// check node is fireEvent or it's alias from the named import
100+
const fireEventAliasOrWildcard = isIdentifier(util)
101+
? util.name
102+
: util.local.name;
103+
136104
const fireEventUsed =
137-
isIdentifier(node.object) && node.object.name === fireEventAlias;
105+
isIdentifier(node.object) &&
106+
node.object.name === fireEventAliasOrWildcard;
107+
138108
const fireEventFromWildcardUsed =
139109
isMemberExpression(node.object) &&
140110
isIdentifier(node.object.object) &&
141-
node.object.object.name === wildcardImportName &&
111+
node.object.object.name === fireEventAliasOrWildcard &&
142112
isIdentifier(node.object.property) &&
143113
node.object.property.name === 'fireEvent';
144114

145115
if (!fireEventUsed && !fireEventFromWildcardUsed) {
116+
// fireEvent was imported but it was not used
146117
return;
147118
}
148119

tests/lib/rules/prefer-user-event.test.ts

+112-1
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,67 @@ ruleTester.run(RULE_NAME, rule, {
105105
fireEvent()
106106
`,
107107
})),
108+
{
109+
settings: {
110+
'testing-library/module': 'test-utils',
111+
},
112+
code: `
113+
import { screen } from 'test-utils'
114+
const element = screen.getByText(foo)
115+
`,
116+
},
117+
{
118+
settings: {
119+
'testing-library/module': 'test-utils',
120+
},
121+
code: `
122+
import { render } from 'test-utils'
123+
const utils = render(baz)
124+
const element = utils.getByText(foo)
125+
`,
126+
},
127+
...UserEventMethods.map((userEventMethod) => ({
128+
settings: {
129+
'testing-library/module': 'test-utils',
130+
},
131+
code: `
132+
import userEvent from 'test-utils'
133+
const node = document.createElement(elementType)
134+
userEvent.${userEventMethod}(foo)
135+
`,
136+
})),
137+
...Object.keys(MappingToUserEvent).map((fireEventMethod: string) => ({
138+
settings: {
139+
'testing-library/module': 'test-utils',
140+
},
141+
code: `
142+
import { fireEvent } from 'test-utils'
143+
const node = document.createElement(elementType)
144+
fireEvent.${fireEventMethod}(foo)
145+
`,
146+
options: [{ allowedMethods: [fireEventMethod] }],
147+
})),
148+
...Object.keys(MappingToUserEvent).map((fireEventMethod: string) => ({
149+
settings: {
150+
'testing-library/module': 'test-utils',
151+
},
152+
code: `
153+
import { fireEvent as fireEventAliased } from 'test-utils'
154+
const node = document.createElement(elementType)
155+
fireEventAliased.${fireEventMethod}(foo)
156+
`,
157+
options: [{ allowedMethods: [fireEventMethod] }],
158+
})),
159+
...Object.keys(MappingToUserEvent).map((fireEventMethod: string) => ({
160+
settings: {
161+
'testing-library/module': 'test-utils',
162+
},
163+
code: `
164+
import * as dom from 'test-utils'
165+
dom.fireEvent.${fireEventMethod}(foo)
166+
`,
167+
options: [{ allowedMethods: [fireEventMethod] }],
168+
})),
108169
],
109170
invalid: [
110171
...createScenarioWithImport<InvalidTestCase<MessageIds, Options>>(
@@ -117,6 +178,8 @@ ruleTester.run(RULE_NAME, rule, {
117178
errors: [
118179
{
119180
messageId: 'preferUserEvent',
181+
line: 4,
182+
column: 9,
120183
},
121184
],
122185
})
@@ -127,8 +190,56 @@ ruleTester.run(RULE_NAME, rule, {
127190
import * as dom from '${libraryModule}'
128191
dom.fireEvent.${fireEventMethod}(foo)
129192
`,
130-
errors: [{ messageId: 'preferUserEvent' }],
193+
errors: [{ messageId: 'preferUserEvent', line: 3, column: 9 }],
131194
})
132195
),
196+
...createScenarioWithImport<InvalidTestCase<MessageIds, Options>>(
197+
(libraryModule: string, fireEventMethod: string) => ({
198+
code: `
199+
const { fireEvent } = require('${libraryModule}')
200+
fireEvent.${fireEventMethod}(foo)
201+
`,
202+
errors: [{ messageId: 'preferUserEvent', line: 3, column: 9 }],
203+
})
204+
),
205+
...createScenarioWithImport<InvalidTestCase<MessageIds, Options>>(
206+
(libraryModule: string, fireEventMethod: string) => ({
207+
code: `
208+
const rtl = require('${libraryModule}')
209+
rtl.fireEvent.${fireEventMethod}(foo)
210+
`,
211+
errors: [{ messageId: 'preferUserEvent', line: 3, column: 9 }],
212+
})
213+
),
214+
...Object.keys(MappingToUserEvent).map((fireEventMethod: string) => ({
215+
settings: {
216+
'testing-library/module': 'test-utils',
217+
},
218+
code: `
219+
import * as dom from 'test-utils'
220+
dom.fireEvent.${fireEventMethod}(foo)
221+
`,
222+
errors: [{ messageId: 'preferUserEvent', line: 3, column: 9 }],
223+
})),
224+
...Object.keys(MappingToUserEvent).map((fireEventMethod: string) => ({
225+
settings: {
226+
'testing-library/module': 'test-utils',
227+
},
228+
code: `
229+
import { fireEvent } from 'test-utils'
230+
fireEvent.${fireEventMethod}(foo)
231+
`,
232+
errors: [{ messageId: 'preferUserEvent', line: 3, column: 9 }],
233+
})),
234+
...Object.keys(MappingToUserEvent).map((fireEventMethod: string) => ({
235+
settings: {
236+
'testing-library/module': 'test-utils',
237+
},
238+
code: `
239+
import { fireEvent as fireEventAliased } from 'test-utils'
240+
fireEventAliased.${fireEventMethod}(foo)
241+
`,
242+
errors: [{ messageId: 'preferUserEvent', line: 3, column: 9 }],
243+
})),
133244
],
134245
});

0 commit comments

Comments
 (0)