Skip to content

Commit c7c60c9

Browse files
committed
feat: adding prefer-user-event rule
1 parent b2ef721 commit c7c60c9

File tree

8 files changed

+400
-8
lines changed

8 files changed

+400
-8
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ To enable this configuration use the `extends` property in your
143143
| [prefer-find-by](docs/rules/prefer-find-by.md) | Suggest using `findBy*` methods instead of the `waitFor` + `getBy` queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |
144144
| [prefer-presence-queries](docs/rules/prefer-presence-queries.md) | Enforce specific queries when checking element is present or not | | |
145145
| [prefer-screen-queries](docs/rules/prefer-screen-queries.md) | Suggest using screen while using queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
146+
| [prefer-user-event](docs/rules/prefer-user-event.md) | Suggest using `userEvent` library instead of `fireEvent` for simulating user interaction | ![react-badge][] | |
146147
| [prefer-wait-for](docs/rules/prefer-wait-for.md) | Use `waitFor` instead of deprecated wait methods | | ![fixable-badge][] |
147148

148149
[build-badge]: https://img.shields.io/travis/testing-library/eslint-plugin-testing-library?style=flat-square

docs/rules/prefer-user-event.md

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Use [userEvent](https://github.com/testing-library/user-event) over using `fireEvent` for user interactions (prefer-user-event)
2+
3+
From
4+
[testing-library/dom-testing-library#107](https://github.com/testing-library/dom-testing-library/issues/107):
5+
6+
> [...] it is becoming apparent the need to express user actions on a web page
7+
> using a higher-level abstraction than `fireEvent`
8+
9+
`userEvent` adds related event calls from browsers to make tests more realistic than its counterpart `fireEvent`, which is a low-level api.
10+
See the appendix at the end to check how are the events from `fireEvent` mapped to `userEvent`
11+
12+
## Rule Details
13+
14+
This rules enforces the usage of [userEvent](https://github.com/testing-library/user-event) methods over `fireEvent`. By default, the methods from `userEvent` takes precedence, but you add exceptions by configuring the rule in `.eslintrc`
15+
16+
See below for examples of valid usages of `fireEvent` methods with the configuration.
17+
18+
Examples of **incorrect** code for this rule:
19+
20+
```ts
21+
// a method in fireEvent that has a userEvent equivalent
22+
import { fireEvent } from '@testing-library/dom';
23+
fireEvent.click(node);
24+
25+
// using fireEvent with an alias
26+
import { fireEvent as fireEventAliased } from '@testing-library/dom';
27+
fireEventAliased.click(node);
28+
29+
// using fireEvent after importing the entire library
30+
import * as dom from '@testing-library/dom';
31+
dom.fireEvent.click(node);
32+
```
33+
34+
Examples of **correct** code for this rule:
35+
36+
```ts
37+
// any userEvent method
38+
userEvent.click();
39+
// fireEvent method that does not have an alternative in userEvent
40+
fireEvent.cut(node);
41+
import * as dom from '@testing-library/dom';
42+
dom.fireEvent.cut(node);
43+
44+
// a function called fireEvent that's not imported from testing-library
45+
function fireEvent() {
46+
// do stuff
47+
}
48+
fireEvent();
49+
```
50+
51+
#### Options
52+
53+
This rule allows to exclude specific functions with an equivalent in `userEvent` through configuration. This is useful if you need to allow an event from `fireEvent` to be used in the solution. For specific scenarios, you might want to consider disabling the rule inline.
54+
55+
The configuration consists of an array of strings with the names of fireEvents methods to be excluded.
56+
An example looks like this
57+
58+
```json
59+
{
60+
"rules": {
61+
"prefer-user-event": [
62+
"error",
63+
{
64+
"allowedMethods": ["click", "change"]
65+
}
66+
]
67+
}
68+
}
69+
```
70+
71+
With this configuration example, the following use cases are considered valid
72+
73+
```ts
74+
// using a named import
75+
import { fireEvent } from '@testing-library/dom';
76+
fireEvent.click(node);
77+
fireEvent.change(node, { target: { value: 'foo' } });
78+
79+
// using fireEvent with an alias
80+
import { fireEvent as fireEventAliased } from '@testing-library/dom';
81+
fireEventAliased.click(node);
82+
fireEventAliased.change(node, { target: { value: 'foo' } });
83+
84+
// using fireEvent after importing the entire library
85+
import * as dom from '@testing-library/dom';
86+
dom.fireEvent.click(node);
87+
dom.fireEvent.change(node, { target: { value: 'foo' } });
88+
```
89+
90+
## When Not To Use It
91+
92+
When you don't want to encourage the usage of `userEvent`
93+
94+
## Further Reading
95+
96+
- [userEvent repository](https://github.com/testing-library/user-event)
97+
- [userEvent in the react-testing-library docs](https://testing-library.com/docs/ecosystem-user-event)
98+
99+
## Appendix
100+
101+
The following table lists all the possible equivalents from the low-level API `fireEvent` to the higher abstraction API `userEvent`. All the events not listed here do not have an equivalent (yet)
102+
103+
| fireEvent method | Possible options in userEvent |
104+
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
105+
| `fireEvent.click()` | <ul><li>`userEvent.click()`</li><li>`userEvent.type()`</li><li>`userEvent.selectOptions()`</li><li>`userEvent.deselectOptions()`</li></ul> |
106+
| `fireEvent.change()` | <ul><li>`userEvent.upload()`</li><li>`userEvent.type()`</li><li>`userEvent.clear()`</li><li>`userEvent.selectOptions()`</li><li>`userEvent.deselectOptions()`</li></ul> |
107+
| `fireEvent.dblClick()` | <ul><li>`userEvent.dblClick()`</li></ul> |
108+
| `fireEvent.input()` | <ul><li>`userEvent.type()`</li><li>`userEvent.upload()`</li><li>`userEvent.selectOptions()`</li><li>`userEvent.deselectOptions()`</li><li>`userEvent.paste()`</li></ul> |
109+
| `fireEvent.keyDown()` | <ul><li>`userEvent.type()`</li><li>`userEvent.tab()`</li></ul> |
110+
| `fireEvent.keyPress()` | <ul><li>`userEvent.type()`</li></ul> |
111+
| `fireEvent.keyUp()` | <ul><li>`userEvent.type()`</li><li>`userEvent.tab()`</li></ul> |
112+
| `fireEvent.mouseDown()` | <ul><li>`userEvent.click()`</li><li>`userEvent.dblClick()`</li><li>`userEvent.selectOptions()`</li><li>`userEvent.deselectOptions()`</li></ul> |
113+
| `fireEvent.mouseEnter()` | <ul><li>`userEvent.hover()`</li><li>`userEvent.selectOptions()`</li><li>`userEvent.deselectOptions()`</li></ul> |
114+
| `fireEvent.mouseLeave()` | <ul><li>`userEvent.unhover()`</li></ul> |
115+
| `fireEvent.mouseMove()` | <ul><li>`userEvent.hover()`</li><li>`userEvent.unhover()`</li><li>`userEvent.selectOptions()`</li><li>`userEvent.deselectOptions()`</li></ul> |
116+
| `fireEvent.mouseOut()` | <ul><li>`userEvent.unhover()`</li></ul> |
117+
| `fireEvent.mouseOver()` | <ul><li>`userEvent.hover()`</li><li>`userEvent.selectOptions()`</li><li>`userEvent.deselectOptions()`</li></ul> |
118+
| `fireEvent.mouseUp()` | <ul><li>`userEvent.click()`</li><li>`userEvent.dblClick()`</li><li>`userEvent.selectOptions()`</li><li>`userEvent.deselectOptions()`</li></ul> |
119+
| `fireEvent.paste()` | <ul><li>`userEvent.paste()`</li></ul> |
120+
| `fireEvent.pointerDown()` | <ul><li>`userEvent.click()`</li><li>`userEvent.dblClick()`</li><li>`userEvent.selectOptions()`</li><li>`userEvent.deselectOptions()`</li></ul> |
121+
| `fireEvent.pointerEnter()` | <ul><li>`userEvent.hover()`</li><li>`userEvent.selectOptions()`</li><li>`userEvent.deselectOptions()`</li></ul> |
122+
| `fireEvent.pointerLeave()` | <ul><li>`userEvent.unhover()`</li></ul> |
123+
| `fireEvent.pointerMove()` | <ul><li>`userEvent.hover()`</li><li>`userEvent.unhover()`</li><li>`userEvent.selectOptions()`</li><li>`userEvent.deselectOptions()`</li></ul> |
124+
| `fireEvent.pointerOut()` | <ul><li>`userEvent.unhover()`</li></ul> |
125+
| `fireEvent.pointerOver()` | <ul><li>`userEvent.hover()`</li><li>`userEvent.selectOptions()`</li><li>`userEvent.deselectOptions()`</li></ul> |
126+
| `fireEvent.pointerUp()` | <ul><li>`userEvent.click()`</li><li>`userEvent.dblClick()`</li><li>`userEvent.selectOptions()`</li><li>`userEvent.deselectOptions()`</li></ul> |

lib/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import noPromiseInFireEvent from './rules/no-promise-in-fire-event';
1212
import preferExplicitAssert from './rules/prefer-explicit-assert';
1313
import preferPresenceQueries from './rules/prefer-presence-queries';
1414
import preferScreenQueries from './rules/prefer-screen-queries';
15+
import preferUserEvent from './rules/prefer-user-event';
1516
import preferWaitFor from './rules/prefer-wait-for';
1617
import noMultipleAssertionsWaitFor from './rules/no-multiple-assertions-wait-for'
1718
import preferFindBy from './rules/prefer-find-by';
@@ -33,6 +34,7 @@ const rules = {
3334
'prefer-find-by': preferFindBy,
3435
'prefer-presence-queries': preferPresenceQueries,
3536
'prefer-screen-queries': preferScreenQueries,
37+
'prefer-user-event': preferUserEvent,
3638
'prefer-wait-for': preferWaitFor,
3739
};
3840

@@ -58,6 +60,7 @@ const reactRules = {
5860
'testing-library/no-container': 'error',
5961
'testing-library/no-debug': 'warn',
6062
'testing-library/no-dom-import': ['error', 'react'],
63+
'testing-library/prefer-user-event': 'error',
6164
};
6265

6366
const vueRules = {

lib/rules/no-debug.ts

+1-8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils';
2-
import { getDocsUrl, LIBRARY_MODULES } from '../utils';
2+
import { getDocsUrl, LIBRARY_MODULES, hasTestingLibraryImportModule } from '../utils';
33
import {
44
isObjectPattern,
55
isProperty,
@@ -13,13 +13,6 @@ import {
1313

1414
export const RULE_NAME = 'no-debug';
1515

16-
function hasTestingLibraryImportModule(
17-
importDeclarationNode: TSESTree.ImportDeclaration
18-
) {
19-
const literal = importDeclarationNode.source;
20-
return LIBRARY_MODULES.some(module => module === literal.value);
21-
}
22-
2316
export default ESLintUtils.RuleCreator(getDocsUrl)({
2417
name: RULE_NAME,
2518
meta: {

lib/rules/prefer-user-event.ts

+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils';
2+
import { getDocsUrl, hasTestingLibraryImportModule } from '../utils';
3+
import { isImportSpecifier, isIdentifier, isMemberExpression } from '../node-utils'
4+
5+
export const RULE_NAME = 'prefer-user-event'
6+
7+
export type MessageIds = 'preferUserEvent'
8+
export type Options = [{ allowedMethods: string[] }];
9+
10+
export const UserEventMethods = ['click', 'dblClick', 'type', 'upload', 'clear', 'selectOptions', 'deselectOptions', 'tab', 'hover', 'unhover', 'paste'] as const
11+
type UserEventMethodsType = typeof UserEventMethods[number]
12+
13+
// maps fireEvent methods to userEvent. Those not found here, do not have an equivalet (yet)
14+
export const MappingToUserEvent: Record<string, UserEventMethodsType[]> = {
15+
click: ['click', 'type', 'selectOptions', 'deselectOptions'],
16+
change: ['upload', 'type', 'clear', 'selectOptions', 'deselectOptions'],
17+
dblClick: ['dblClick'],
18+
input: ['type', 'upload', 'selectOptions', 'deselectOptions', 'paste'],
19+
keyDown: ['type', 'tab'],
20+
keyPress: ['type'],
21+
keyUp: ['type', 'tab'],
22+
mouseDown: ['click', 'dblClick', 'selectOptions', 'deselectOptions'],
23+
mouseEnter: ['hover', 'selectOptions', 'deselectOptions'],
24+
mouseLeave: ['unhover'],
25+
mouseMove: ['hover', 'unhover', 'selectOptions', 'deselectOptions'],
26+
mouseOut: ['unhover'],
27+
mouseOver: ['hover', 'selectOptions', 'deselectOptions'],
28+
mouseUp: ['click', 'dblClick', 'selectOptions', 'deselectOptions'],
29+
paste: ['paste'],
30+
pointerDown: ['click', 'dblClick', 'selectOptions', 'deselectOptions'],
31+
pointerEnter: ['hover', 'selectOptions', 'deselectOptions'],
32+
pointerLeave: ['unhover'],
33+
pointerMove: ['hover', 'unhover', 'selectOptions', 'deselectOptions'],
34+
pointerOut: ['unhover'],
35+
pointerOver: ['hover', 'selectOptions', 'deselectOptions'],
36+
pointerUp: ['click', 'dblClick', 'selectOptions', 'deselectOptions'],
37+
}
38+
39+
function buildErrorMessage(fireEventMethod: string) {
40+
const allMethods = MappingToUserEvent[fireEventMethod].map((method: string) => `userEvent.${method}()`)
41+
42+
let userEventMethods = ''
43+
if (allMethods.length > 2) {
44+
userEventMethods += allMethods.slice(0, allMethods.length - 2).join(', ')
45+
}
46+
47+
userEventMethods += `${allMethods.length > 1 ? ' or ' : ''}${allMethods[allMethods.length - 1]}`
48+
return userEventMethods
49+
}
50+
51+
const fireEventMappedMethods = Object.keys(MappingToUserEvent)
52+
53+
export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
54+
name: RULE_NAME,
55+
meta: {
56+
type: "suggestion",
57+
docs: {
58+
description: 'Suggest using userEvent over fireEvent',
59+
category: 'Best Practices',
60+
recommended: 'warn'
61+
},
62+
messages: {
63+
preferUserEvent: 'Prefer using {{userEventMethods}} over {{fireEventMethod}}()'
64+
},
65+
schema: [{
66+
type: 'object',
67+
properties: {
68+
allowedMethods: { type: 'array' },
69+
},
70+
}],
71+
fixable: null,
72+
},
73+
defaultOptions: [{ allowedMethods: [] }],
74+
75+
create(context, [options]) {
76+
const { allowedMethods } = options
77+
const sourceCode = context.getSourceCode();
78+
let hasNamedImportedFireEvent = false
79+
let hasImportedFireEvent = false
80+
let fireEventAlias: string | undefined
81+
let wildcardImportName: string | undefined
82+
83+
return {
84+
// checks if import has shape:
85+
// import { fireEvent } from '@testing-library/dom';
86+
ImportDeclaration(node: TSESTree.ImportDeclaration) {
87+
if (!hasTestingLibraryImportModule(node)) {
88+
return
89+
};
90+
const fireEventImport = node.specifiers.find((node) => isImportSpecifier(node) && node.imported.name === 'fireEvent')
91+
hasNamedImportedFireEvent = !!fireEventImport
92+
if (!hasNamedImportedFireEvent) {
93+
return
94+
}
95+
fireEventAlias = fireEventImport.local.name
96+
},
97+
98+
// checks if import has shape:
99+
// import * as dom from '@testing-library/dom';
100+
'ImportDeclaration ImportNamespaceSpecifier'(
101+
node: TSESTree.ImportNamespaceSpecifier
102+
) {
103+
const importDeclarationNode = node.parent as TSESTree.ImportDeclaration;
104+
if (!hasTestingLibraryImportModule(importDeclarationNode)) {
105+
return
106+
};
107+
hasImportedFireEvent = !!node.local.name
108+
wildcardImportName = node.local.name
109+
},
110+
['CallExpression > MemberExpression'](node: TSESTree.MemberExpression) {
111+
if (!hasImportedFireEvent && !hasNamedImportedFireEvent) {
112+
return
113+
}
114+
// check node is fireEvent or it's alias from the named import
115+
const fireEventUsed = isIdentifier(node.object) && node.object.name === fireEventAlias
116+
const fireEventFromWildcardUsed = isMemberExpression(node.object) && isIdentifier(node.object.object) && node.object.object.name === wildcardImportName && isIdentifier(node.object.property) && node.object.property.name === 'fireEvent'
117+
118+
if (!fireEventUsed && !fireEventFromWildcardUsed) {
119+
return
120+
}
121+
122+
if (!isIdentifier(node.property) || !fireEventMappedMethods.includes(node.property.name) || allowedMethods.includes(node.property.name)) {
123+
// the fire event does not have an equivalent in userEvent, or it's excluded
124+
return
125+
}
126+
127+
context.report({
128+
node,
129+
messageId: 'preferUserEvent',
130+
data: {
131+
userEventMethods: buildErrorMessage(node.property.name),
132+
fireEventMethod: sourceCode.getText(node)
133+
},
134+
})
135+
}
136+
}
137+
}
138+
})

lib/utils.ts

+7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { TSESTree } from '@typescript-eslint/experimental-utils';
2+
13
const combineQueries = (variants: string[], methods: string[]) => {
24
const combinedQueries: string[] = [];
35
variants.forEach(variant => {
@@ -22,6 +24,10 @@ const LIBRARY_MODULES = [
2224
'@testing-library/svelte',
2325
];
2426

27+
const hasTestingLibraryImportModule = (node: TSESTree.ImportDeclaration) => {
28+
return LIBRARY_MODULES.includes(node.source.value.toString())
29+
}
30+
2531
const SYNC_QUERIES_VARIANTS = ['getBy', 'getAllBy', 'queryBy', 'queryAllBy'];
2632
const ASYNC_QUERIES_VARIANTS = ['findBy', 'findAllBy'];
2733
const ALL_QUERIES_VARIANTS = [
@@ -65,6 +71,7 @@ const ASYNC_UTILS = [
6571

6672
export {
6773
getDocsUrl,
74+
hasTestingLibraryImportModule,
6875
SYNC_QUERIES_VARIANTS,
6976
ASYNC_QUERIES_VARIANTS,
7077
ALL_QUERIES_VARIANTS,

tests/__snapshots__/index.test.ts.snap

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Object {
5959
"testing-library/no-wait-for-empty-callback": "error",
6060
"testing-library/prefer-find-by": "error",
6161
"testing-library/prefer-screen-queries": "error",
62+
"testing-library/prefer-user-event": "error",
6263
},
6364
}
6465
`;

0 commit comments

Comments
 (0)