Skip to content

Commit 9aa6730

Browse files
authored
feat: add prefer-user-event rule (#192)
1 parent 9d4c1e4 commit 9aa6730

File tree

8 files changed

+380
-8
lines changed

8 files changed

+380
-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-explicit-assert](docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | |
144144
| [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][] |
145145
| [prefer-presence-queries](docs/rules/prefer-presence-queries.md) | Enforce specific queries when checking element is present or not | | |
146+
| [prefer-user-event](docs/rules/prefer-user-event.md) | Suggest using `userEvent` library instead of `fireEvent` for simulating user interaction | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
146147
| [prefer-screen-queries](docs/rules/prefer-screen-queries.md) | Suggest using screen while using queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
147148
| [prefer-wait-for](docs/rules/prefer-wait-for.md) | Use `waitFor` instead of deprecated wait methods | | ![fixable-badge][] |
148149

docs/rules/prefer-user-event.md

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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 rule enforces the usage of [userEvent](https://github.com/testing-library/user-event) methods over `fireEvent`. By default, the methods from `userEvent` take precedence, but you add exceptions by configuring the rule in `.eslintrc`.
15+
16+
Examples of **incorrect** code for this rule:
17+
18+
```ts
19+
// a method in fireEvent that has a userEvent equivalent
20+
import { fireEvent } from '@testing-library/dom';
21+
fireEvent.click(node);
22+
23+
// using fireEvent with an alias
24+
import { fireEvent as fireEventAliased } from '@testing-library/dom';
25+
fireEventAliased.click(node);
26+
27+
// using fireEvent after importing the entire library
28+
import * as dom from '@testing-library/dom';
29+
dom.fireEvent.click(node);
30+
```
31+
32+
Examples of **correct** code for this rule:
33+
34+
```ts
35+
import userEvent from '@testing-library/user-event';
36+
37+
// any userEvent method
38+
userEvent.click();
39+
40+
// fireEvent method that does not have an alternative in userEvent
41+
fireEvent.cut(node);
42+
43+
import * as dom from '@testing-library/dom';
44+
dom.fireEvent.cut(node);
45+
```
46+
47+
#### Options
48+
49+
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.
50+
51+
The configuration consists of an array of strings with the names of fireEvents methods to be excluded.
52+
An example looks like this
53+
54+
```json
55+
{
56+
"rules": {
57+
"prefer-user-event": [
58+
"error",
59+
{
60+
"allowedMethods": ["click", "change"]
61+
}
62+
]
63+
}
64+
}
65+
```
66+
67+
With this configuration example, the following use cases are considered valid
68+
69+
```ts
70+
// using a named import
71+
import { fireEvent } from '@testing-library/dom';
72+
fireEvent.click(node);
73+
fireEvent.change(node, { target: { value: 'foo' } });
74+
75+
// using fireEvent with an alias
76+
import { fireEvent as fireEventAliased } from '@testing-library/dom';
77+
fireEventAliased.click(node);
78+
fireEventAliased.change(node, { target: { value: 'foo' } });
79+
80+
// using fireEvent after importing the entire library
81+
import * as dom from '@testing-library/dom';
82+
dom.fireEvent.click(node);
83+
dom.fireEvent.change(node, { target: { value: 'foo' } });
84+
```
85+
86+
## When Not To Use It
87+
88+
When you don't want to use `userEvent`, such as if a legacy codebase is still using `fireEvent` or you need to have more low-level control over firing events (rather than the recommended approach of testing from a user's perspective)
89+
90+
## Further Reading
91+
92+
- [userEvent repository](https://github.com/testing-library/user-event)
93+
- [userEvent in the react-testing-library docs](https://testing-library.com/docs/ecosystem-user-event)
94+
95+
## Appendix
96+
97+
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)
98+
99+
| fireEvent method | Possible options in userEvent |
100+
| ---------------- | ----------------------------------------------------------------------------------------------------------- |
101+
| `click` | <ul><li>`click`</li><li>`type`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> |
102+
| `change` | <ul><li>`upload`</li><li>`type`</li><li>`clear`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> |
103+
| `dblClick` | <ul><li>`dblClick`</li></ul> |
104+
| `input` | <ul><li>`type`</li><li>`upload`</li><li>`selectOptions`</li><li>`deselectOptions`</li><li>`paste`</li></ul> |
105+
| `keyDown` | <ul><li>`type`</li><li>`tab`</li></ul> |
106+
| `keyPress` | <ul><li>`type`</li></ul> |
107+
| `keyUp` | <ul><li>`type`</li><li>`tab`</li></ul> |
108+
| `mouseDown` | <ul><li>`click`</li><li>`dblClick`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> |
109+
| `mouseEnter` | <ul><li>`hover`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> |
110+
| `mouseLeave` | <ul><li>`unhover`</li></ul> |
111+
| `mouseMove` | <ul><li>`hover`</li><li>`unhover`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> |
112+
| `mouseOut` | <ul><li>`unhover`</li></ul> |
113+
| `mouseOver` | <ul><li>`hover`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> |
114+
| `mouseUp` | <ul><li>`click`</li><li>`dblClick`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> |
115+
| `paste` | <ul><li>`paste`</li></ul> |
116+
| `pointerDown` | <ul><li>`click`</li><li>`dblClick`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> |
117+
| `pointerEnter` | <ul><li>`hover`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> |
118+
| `pointerLeave` | <ul><li>`unhover`</li></ul> |
119+
| `pointerMove` | <ul><li>`hover`</li><li>`unhover`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> |
120+
| `pointerOut` | <ul><li>`unhover`</li></ul> |
121+
| `pointerOver` | <ul><li>`hover`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> |
122+
| `pointerUp` | <ul><li>`click`</li><li>`dblClick`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> |

lib/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import noPromiseInFireEvent from './rules/no-promise-in-fire-event';
1313
import preferExplicitAssert from './rules/prefer-explicit-assert';
1414
import preferPresenceQueries from './rules/prefer-presence-queries';
1515
import preferScreenQueries from './rules/prefer-screen-queries';
16+
import preferUserEvent from './rules/prefer-user-event';
1617
import preferWaitFor from './rules/prefer-wait-for';
1718
import noMultipleAssertionsWaitFor from './rules/no-multiple-assertions-wait-for'
1819
import preferFindBy from './rules/prefer-find-by';
@@ -35,6 +36,7 @@ const rules = {
3536
'prefer-find-by': preferFindBy,
3637
'prefer-presence-queries': preferPresenceQueries,
3738
'prefer-screen-queries': preferScreenQueries,
39+
'prefer-user-event': preferUserEvent,
3840
'prefer-wait-for': preferWaitFor,
3941
};
4042

@@ -46,6 +48,7 @@ const domRules = {
4648
'testing-library/no-wait-for-empty-callback': 'error',
4749
'testing-library/prefer-find-by': 'error',
4850
'testing-library/prefer-screen-queries': 'error',
51+
'testing-library/prefer-user-event': 'warn',
4952
};
5053

5154
const angularRules = {

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

+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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+
const { length } = allMethods
42+
43+
const init = length > 2 ? allMethods.slice(0, length - 2).join(', ') : ''
44+
const last = `${length > 1 ? ' or ' : ''}${allMethods[length - 1]}`
45+
return `${init}${last}`
46+
}
47+
48+
const fireEventMappedMethods = Object.keys(MappingToUserEvent)
49+
50+
export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
51+
name: RULE_NAME,
52+
meta: {
53+
type: "suggestion",
54+
docs: {
55+
description: 'Suggest using userEvent over fireEvent',
56+
category: 'Best Practices',
57+
recommended: 'warn'
58+
},
59+
messages: {
60+
preferUserEvent: 'Prefer using {{userEventMethods}} over {{fireEventMethod}}()'
61+
},
62+
schema: [{
63+
type: 'object',
64+
properties: {
65+
allowedMethods: { type: 'array' },
66+
},
67+
}],
68+
fixable: null,
69+
},
70+
defaultOptions: [{ allowedMethods: [] }],
71+
72+
create(context, [options]) {
73+
const { allowedMethods } = options
74+
const sourceCode = context.getSourceCode();
75+
let hasNamedImportedFireEvent = false
76+
let hasImportedFireEvent = false
77+
let fireEventAlias: string | undefined
78+
let wildcardImportName: string | undefined
79+
80+
return {
81+
// checks if import has shape:
82+
// import { fireEvent } from '@testing-library/dom';
83+
ImportDeclaration(node: TSESTree.ImportDeclaration) {
84+
if (!hasTestingLibraryImportModule(node)) {
85+
return
86+
};
87+
const fireEventImport = node.specifiers.find((node) => isImportSpecifier(node) && node.imported.name === 'fireEvent')
88+
hasNamedImportedFireEvent = !!fireEventImport
89+
if (!hasNamedImportedFireEvent) {
90+
return
91+
}
92+
fireEventAlias = fireEventImport.local.name
93+
},
94+
95+
// checks if import has shape:
96+
// import * as dom from '@testing-library/dom';
97+
'ImportDeclaration ImportNamespaceSpecifier'(
98+
node: TSESTree.ImportNamespaceSpecifier
99+
) {
100+
const importDeclarationNode = node.parent as TSESTree.ImportDeclaration;
101+
if (!hasTestingLibraryImportModule(importDeclarationNode)) {
102+
return
103+
};
104+
hasImportedFireEvent = !!node.local.name
105+
wildcardImportName = node.local.name
106+
},
107+
['CallExpression > MemberExpression'](node: TSESTree.MemberExpression) {
108+
if (!hasImportedFireEvent && !hasNamedImportedFireEvent) {
109+
return
110+
}
111+
// check node is fireEvent or it's alias from the named import
112+
const fireEventUsed = isIdentifier(node.object) && node.object.name === fireEventAlias
113+
const fireEventFromWildcardUsed = isMemberExpression(node.object) && isIdentifier(node.object.object) && node.object.object.name === wildcardImportName && isIdentifier(node.object.property) && node.object.property.name === 'fireEvent'
114+
115+
if (!fireEventUsed && !fireEventFromWildcardUsed) {
116+
return
117+
}
118+
119+
if (!isIdentifier(node.property) || !fireEventMappedMethods.includes(node.property.name) || allowedMethods.includes(node.property.name)) {
120+
// the fire event does not have an equivalent in userEvent, or it's excluded
121+
return
122+
}
123+
124+
context.report({
125+
node,
126+
messageId: 'preferUserEvent',
127+
data: {
128+
userEventMethods: buildErrorMessage(node.property.name),
129+
fireEventMethod: sourceCode.getText(node)
130+
},
131+
})
132+
}
133+
}
134+
}
135+
})

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 = [
@@ -100,6 +106,7 @@ const ALL_RETURNING_NODES = [
100106

101107
export {
102108
getDocsUrl,
109+
hasTestingLibraryImportModule,
103110
SYNC_QUERIES_VARIANTS,
104111
ASYNC_QUERIES_VARIANTS,
105112
ALL_QUERIES_VARIANTS,

tests/__snapshots__/index.test.ts.snap

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Object {
2020
"testing-library/no-wait-for-empty-callback": "error",
2121
"testing-library/prefer-find-by": "error",
2222
"testing-library/prefer-screen-queries": "error",
23+
"testing-library/prefer-user-event": "warn",
2324
},
2425
}
2526
`;
@@ -37,6 +38,7 @@ Object {
3738
"testing-library/no-wait-for-empty-callback": "error",
3839
"testing-library/prefer-find-by": "error",
3940
"testing-library/prefer-screen-queries": "error",
41+
"testing-library/prefer-user-event": "warn",
4042
},
4143
}
4244
`;
@@ -61,6 +63,7 @@ Object {
6163
"testing-library/no-wait-for-empty-callback": "error",
6264
"testing-library/prefer-find-by": "error",
6365
"testing-library/prefer-screen-queries": "error",
66+
"testing-library/prefer-user-event": "warn",
6467
},
6568
}
6669
`;
@@ -86,6 +89,7 @@ Object {
8689
"testing-library/no-wait-for-empty-callback": "error",
8790
"testing-library/prefer-find-by": "error",
8891
"testing-library/prefer-screen-queries": "error",
92+
"testing-library/prefer-user-event": "warn",
8993
},
9094
}
9195
`;

0 commit comments

Comments
 (0)