-
Notifications
You must be signed in to change notification settings - Fork 150
[new-rule] prefer-user-event #192
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
# Use [userEvent](https://github.com/testing-library/user-event) over using `fireEvent` for user interactions (prefer-user-event) | ||
|
||
From | ||
[testing-library/dom-testing-library#107](https://github.com/testing-library/dom-testing-library/issues/107): | ||
|
||
> [...] it is becoming apparent the need to express user actions on a web page | ||
> using a higher-level abstraction than `fireEvent` | ||
|
||
`userEvent` adds related event calls from browsers to make tests more realistic than its counterpart `fireEvent`, which is a low-level api. | ||
See the appendix at the end to check how are the events from `fireEvent` mapped to `userEvent`. | ||
|
||
## Rule Details | ||
|
||
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`. | ||
|
||
Examples of **incorrect** code for this rule: | ||
|
||
```ts | ||
// a method in fireEvent that has a userEvent equivalent | ||
import { fireEvent } from '@testing-library/dom'; | ||
fireEvent.click(node); | ||
|
||
// using fireEvent with an alias | ||
import { fireEvent as fireEventAliased } from '@testing-library/dom'; | ||
fireEventAliased.click(node); | ||
|
||
// using fireEvent after importing the entire library | ||
import * as dom from '@testing-library/dom'; | ||
dom.fireEvent.click(node); | ||
``` | ||
|
||
Examples of **correct** code for this rule: | ||
|
||
```ts | ||
import userEvent from '@testing-library/user-event'; | ||
|
||
// any userEvent method | ||
userEvent.click(); | ||
|
||
// fireEvent method that does not have an alternative in userEvent | ||
timdeschryver marked this conversation as resolved.
Show resolved
Hide resolved
|
||
fireEvent.cut(node); | ||
|
||
import * as dom from '@testing-library/dom'; | ||
gndelia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
dom.fireEvent.cut(node); | ||
``` | ||
|
||
#### Options | ||
|
||
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. | ||
|
||
The configuration consists of an array of strings with the names of fireEvents methods to be excluded. | ||
An example looks like this | ||
|
||
```json | ||
{ | ||
"rules": { | ||
"prefer-user-event": [ | ||
"error", | ||
{ | ||
"allowedMethods": ["click", "change"] | ||
} | ||
] | ||
} | ||
} | ||
``` | ||
|
||
With this configuration example, the following use cases are considered valid | ||
|
||
```ts | ||
// using a named import | ||
import { fireEvent } from '@testing-library/dom'; | ||
fireEvent.click(node); | ||
fireEvent.change(node, { target: { value: 'foo' } }); | ||
|
||
// using fireEvent with an alias | ||
import { fireEvent as fireEventAliased } from '@testing-library/dom'; | ||
fireEventAliased.click(node); | ||
fireEventAliased.change(node, { target: { value: 'foo' } }); | ||
|
||
// using fireEvent after importing the entire library | ||
import * as dom from '@testing-library/dom'; | ||
dom.fireEvent.click(node); | ||
dom.fireEvent.change(node, { target: { value: 'foo' } }); | ||
``` | ||
|
||
## When Not To Use It | ||
|
||
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) | ||
|
||
## Further Reading | ||
|
||
- [userEvent repository](https://github.com/testing-library/user-event) | ||
- [userEvent in the react-testing-library docs](https://testing-library.com/docs/ecosystem-user-event) | ||
|
||
## Appendix | ||
|
||
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) | ||
|
||
| fireEvent method | Possible options in userEvent | | ||
| ---------------- | ----------------------------------------------------------------------------------------------------------- | | ||
| `click` | <ul><li>`click`</li><li>`type`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> | | ||
| `change` | <ul><li>`upload`</li><li>`type`</li><li>`clear`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> | | ||
| `dblClick` | <ul><li>`dblClick`</li></ul> | | ||
| `input` | <ul><li>`type`</li><li>`upload`</li><li>`selectOptions`</li><li>`deselectOptions`</li><li>`paste`</li></ul> | | ||
| `keyDown` | <ul><li>`type`</li><li>`tab`</li></ul> | | ||
| `keyPress` | <ul><li>`type`</li></ul> | | ||
| `keyUp` | <ul><li>`type`</li><li>`tab`</li></ul> | | ||
| `mouseDown` | <ul><li>`click`</li><li>`dblClick`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> | | ||
| `mouseEnter` | <ul><li>`hover`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> | | ||
| `mouseLeave` | <ul><li>`unhover`</li></ul> | | ||
| `mouseMove` | <ul><li>`hover`</li><li>`unhover`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> | | ||
| `mouseOut` | <ul><li>`unhover`</li></ul> | | ||
| `mouseOver` | <ul><li>`hover`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> | | ||
| `mouseUp` | <ul><li>`click`</li><li>`dblClick`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> | | ||
| `paste` | <ul><li>`paste`</li></ul> | | ||
| `pointerDown` | <ul><li>`click`</li><li>`dblClick`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> | | ||
| `pointerEnter` | <ul><li>`hover`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> | | ||
| `pointerLeave` | <ul><li>`unhover`</li></ul> | | ||
| `pointerMove` | <ul><li>`hover`</li><li>`unhover`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> | | ||
| `pointerOut` | <ul><li>`unhover`</li></ul> | | ||
| `pointerOver` | <ul><li>`hover`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> | | ||
| `pointerUp` | <ul><li>`click`</li><li>`dblClick`</li><li>`selectOptions`</li><li>`deselectOptions`</li></ul> | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; | ||
import { getDocsUrl, hasTestingLibraryImportModule } from '../utils'; | ||
import { isImportSpecifier, isIdentifier, isMemberExpression } from '../node-utils' | ||
|
||
export const RULE_NAME = 'prefer-user-event' | ||
|
||
export type MessageIds = 'preferUserEvent' | ||
export type Options = [{ allowedMethods: string[] }]; | ||
|
||
export const UserEventMethods = ['click', 'dblClick', 'type', 'upload', 'clear', 'selectOptions', 'deselectOptions', 'tab', 'hover', 'unhover', 'paste'] as const | ||
type UserEventMethodsType = typeof UserEventMethods[number] | ||
|
||
// maps fireEvent methods to userEvent. Those not found here, do not have an equivalet (yet) | ||
export const MappingToUserEvent: Record<string, UserEventMethodsType[]> = { | ||
click: ['click', 'type', 'selectOptions', 'deselectOptions'], | ||
change: ['upload', 'type', 'clear', 'selectOptions', 'deselectOptions'], | ||
dblClick: ['dblClick'], | ||
input: ['type', 'upload', 'selectOptions', 'deselectOptions', 'paste'], | ||
keyDown: ['type', 'tab'], | ||
keyPress: ['type'], | ||
keyUp: ['type', 'tab'], | ||
mouseDown: ['click', 'dblClick', 'selectOptions', 'deselectOptions'], | ||
mouseEnter: ['hover', 'selectOptions', 'deselectOptions'], | ||
mouseLeave: ['unhover'], | ||
mouseMove: ['hover', 'unhover', 'selectOptions', 'deselectOptions'], | ||
mouseOut: ['unhover'], | ||
mouseOver: ['hover', 'selectOptions', 'deselectOptions'], | ||
mouseUp: ['click', 'dblClick', 'selectOptions', 'deselectOptions'], | ||
paste: ['paste'], | ||
pointerDown: ['click', 'dblClick', 'selectOptions', 'deselectOptions'], | ||
pointerEnter: ['hover', 'selectOptions', 'deselectOptions'], | ||
pointerLeave: ['unhover'], | ||
pointerMove: ['hover', 'unhover', 'selectOptions', 'deselectOptions'], | ||
pointerOut: ['unhover'], | ||
pointerOver: ['hover', 'selectOptions', 'deselectOptions'], | ||
pointerUp: ['click', 'dblClick', 'selectOptions', 'deselectOptions'], | ||
} | ||
|
||
function buildErrorMessage(fireEventMethod: string) { | ||
const allMethods = MappingToUserEvent[fireEventMethod].map((method: string) => `userEvent.${method}()`) | ||
const { length } = allMethods | ||
|
||
const init = length > 2 ? allMethods.slice(0, length - 2).join(', ') : '' | ||
const last = `${length > 1 ? ' or ' : ''}${allMethods[length - 1]}` | ||
return `${init}${last}` | ||
} | ||
|
||
const fireEventMappedMethods = Object.keys(MappingToUserEvent) | ||
|
||
export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({ | ||
name: RULE_NAME, | ||
meta: { | ||
type: "suggestion", | ||
docs: { | ||
description: 'Suggest using userEvent over fireEvent', | ||
category: 'Best Practices', | ||
recommended: 'warn' | ||
}, | ||
messages: { | ||
preferUserEvent: 'Prefer using {{userEventMethods}} over {{fireEventMethod}}()' | ||
}, | ||
schema: [{ | ||
type: 'object', | ||
properties: { | ||
allowedMethods: { type: 'array' }, | ||
}, | ||
}], | ||
fixable: null, | ||
}, | ||
defaultOptions: [{ allowedMethods: [] }], | ||
|
||
create(context, [options]) { | ||
const { allowedMethods } = options | ||
const sourceCode = context.getSourceCode(); | ||
let hasNamedImportedFireEvent = false | ||
let hasImportedFireEvent = false | ||
let fireEventAlias: string | undefined | ||
let wildcardImportName: string | undefined | ||
|
||
return { | ||
// checks if import has shape: | ||
// import { fireEvent } from '@testing-library/dom'; | ||
ImportDeclaration(node: TSESTree.ImportDeclaration) { | ||
if (!hasTestingLibraryImportModule(node)) { | ||
return | ||
}; | ||
const fireEventImport = node.specifiers.find((node) => isImportSpecifier(node) && node.imported.name === 'fireEvent') | ||
hasNamedImportedFireEvent = !!fireEventImport | ||
if (!hasNamedImportedFireEvent) { | ||
return | ||
} | ||
fireEventAlias = fireEventImport.local.name | ||
}, | ||
|
||
// checks if import has shape: | ||
// import * as dom from '@testing-library/dom'; | ||
'ImportDeclaration ImportNamespaceSpecifier'( | ||
node: TSESTree.ImportNamespaceSpecifier | ||
) { | ||
const importDeclarationNode = node.parent as TSESTree.ImportDeclaration; | ||
if (!hasTestingLibraryImportModule(importDeclarationNode)) { | ||
return | ||
}; | ||
hasImportedFireEvent = !!node.local.name | ||
wildcardImportName = node.local.name | ||
}, | ||
['CallExpression > MemberExpression'](node: TSESTree.MemberExpression) { | ||
if (!hasImportedFireEvent && !hasNamedImportedFireEvent) { | ||
return | ||
} | ||
// check node is fireEvent or it's alias from the named import | ||
const fireEventUsed = isIdentifier(node.object) && node.object.name === fireEventAlias | ||
const fireEventFromWildcardUsed = isMemberExpression(node.object) && isIdentifier(node.object.object) && node.object.object.name === wildcardImportName && isIdentifier(node.object.property) && node.object.property.name === 'fireEvent' | ||
|
||
if (!fireEventUsed && !fireEventFromWildcardUsed) { | ||
return | ||
} | ||
|
||
if (!isIdentifier(node.property) || !fireEventMappedMethods.includes(node.property.name) || allowedMethods.includes(node.property.name)) { | ||
// the fire event does not have an equivalent in userEvent, or it's excluded | ||
return | ||
} | ||
|
||
context.report({ | ||
node, | ||
messageId: 'preferUserEvent', | ||
data: { | ||
userEventMethods: buildErrorMessage(node.property.name), | ||
fireEventMethod: sourceCode.getText(node) | ||
}, | ||
}) | ||
} | ||
} | ||
} | ||
}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.