Skip to content

[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 1 commit into from
Jul 20, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ To enable this configuration use the `extends` property in your
| [prefer-explicit-assert](docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | |
| [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][] |
| [prefer-presence-queries](docs/rules/prefer-presence-queries.md) | Enforce specific queries when checking element is present or not | | |
| [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][] | |
| [prefer-screen-queries](docs/rules/prefer-screen-queries.md) | Suggest using screen while using queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [prefer-wait-for](docs/rules/prefer-wait-for.md) | Use `waitFor` instead of deprecated wait methods | | ![fixable-badge][] |

Expand Down
122 changes: 122 additions & 0 deletions docs/rules/prefer-user-event.md
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
fireEvent.cut(node);

import * as dom from '@testing-library/dom';
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> |
3 changes: 3 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import noPromiseInFireEvent from './rules/no-promise-in-fire-event';
import preferExplicitAssert from './rules/prefer-explicit-assert';
import preferPresenceQueries from './rules/prefer-presence-queries';
import preferScreenQueries from './rules/prefer-screen-queries';
import preferUserEvent from './rules/prefer-user-event';
import preferWaitFor from './rules/prefer-wait-for';
import noMultipleAssertionsWaitFor from './rules/no-multiple-assertions-wait-for'
import preferFindBy from './rules/prefer-find-by';
Expand All @@ -35,6 +36,7 @@ const rules = {
'prefer-find-by': preferFindBy,
'prefer-presence-queries': preferPresenceQueries,
'prefer-screen-queries': preferScreenQueries,
'prefer-user-event': preferUserEvent,
'prefer-wait-for': preferWaitFor,
};

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

const angularRules = {
Expand Down
9 changes: 1 addition & 8 deletions lib/rules/no-debug.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils';
import { getDocsUrl, LIBRARY_MODULES } from '../utils';
import { getDocsUrl, LIBRARY_MODULES, hasTestingLibraryImportModule } from '../utils';
import {
isObjectPattern,
isProperty,
Expand All @@ -13,13 +13,6 @@ import {

export const RULE_NAME = 'no-debug';

function hasTestingLibraryImportModule(
importDeclarationNode: TSESTree.ImportDeclaration
) {
const literal = importDeclarationNode.source;
return LIBRARY_MODULES.some(module => module === literal.value);
}

export default ESLintUtils.RuleCreator(getDocsUrl)({
name: RULE_NAME,
meta: {
Expand Down
135 changes: 135 additions & 0 deletions lib/rules/prefer-user-event.ts
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)
},
})
}
}
}
})
7 changes: 7 additions & 0 deletions lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { TSESTree } from '@typescript-eslint/experimental-utils';

const combineQueries = (variants: string[], methods: string[]) => {
const combinedQueries: string[] = [];
variants.forEach(variant => {
Expand All @@ -22,6 +24,10 @@ const LIBRARY_MODULES = [
'@testing-library/svelte',
];

const hasTestingLibraryImportModule = (node: TSESTree.ImportDeclaration) => {
return LIBRARY_MODULES.includes(node.source.value.toString())
}

const SYNC_QUERIES_VARIANTS = ['getBy', 'getAllBy', 'queryBy', 'queryAllBy'];
const ASYNC_QUERIES_VARIANTS = ['findBy', 'findAllBy'];
const ALL_QUERIES_VARIANTS = [
Expand Down Expand Up @@ -100,6 +106,7 @@ const ALL_RETURNING_NODES = [

export {
getDocsUrl,
hasTestingLibraryImportModule,
SYNC_QUERIES_VARIANTS,
ASYNC_QUERIES_VARIANTS,
ALL_QUERIES_VARIANTS,
Expand Down
4 changes: 4 additions & 0 deletions tests/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Object {
"testing-library/no-wait-for-empty-callback": "error",
"testing-library/prefer-find-by": "error",
"testing-library/prefer-screen-queries": "error",
"testing-library/prefer-user-event": "warn",
},
}
`;
Expand All @@ -37,6 +38,7 @@ Object {
"testing-library/no-wait-for-empty-callback": "error",
"testing-library/prefer-find-by": "error",
"testing-library/prefer-screen-queries": "error",
"testing-library/prefer-user-event": "warn",
},
}
`;
Expand All @@ -61,6 +63,7 @@ Object {
"testing-library/no-wait-for-empty-callback": "error",
"testing-library/prefer-find-by": "error",
"testing-library/prefer-screen-queries": "error",
"testing-library/prefer-user-event": "warn",
},
}
`;
Expand All @@ -86,6 +89,7 @@ Object {
"testing-library/no-wait-for-empty-callback": "error",
"testing-library/prefer-find-by": "error",
"testing-library/prefer-screen-queries": "error",
"testing-library/prefer-user-event": "warn",
},
}
`;
Loading