diff --git a/README.md b/README.md
index 2bbe9062..67f525f9 100644
--- a/README.md
+++ b/README.md
@@ -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][] |
diff --git a/docs/rules/prefer-user-event.md b/docs/rules/prefer-user-event.md
new file mode 100644
index 00000000..25aa69e6
--- /dev/null
+++ b/docs/rules/prefer-user-event.md
@@ -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` |
- `click`
- `type`
- `selectOptions`
- `deselectOptions`
|
+| `change` | - `upload`
- `type`
- `clear`
- `selectOptions`
- `deselectOptions`
|
+| `dblClick` | |
+| `input` | - `type`
- `upload`
- `selectOptions`
- `deselectOptions`
- `paste`
|
+| `keyDown` | |
+| `keyPress` | |
+| `keyUp` | |
+| `mouseDown` | - `click`
- `dblClick`
- `selectOptions`
- `deselectOptions`
|
+| `mouseEnter` | - `hover`
- `selectOptions`
- `deselectOptions`
|
+| `mouseLeave` | |
+| `mouseMove` | - `hover`
- `unhover`
- `selectOptions`
- `deselectOptions`
|
+| `mouseOut` | |
+| `mouseOver` | - `hover`
- `selectOptions`
- `deselectOptions`
|
+| `mouseUp` | - `click`
- `dblClick`
- `selectOptions`
- `deselectOptions`
|
+| `paste` | |
+| `pointerDown` | - `click`
- `dblClick`
- `selectOptions`
- `deselectOptions`
|
+| `pointerEnter` | - `hover`
- `selectOptions`
- `deselectOptions`
|
+| `pointerLeave` | |
+| `pointerMove` | - `hover`
- `unhover`
- `selectOptions`
- `deselectOptions`
|
+| `pointerOut` | |
+| `pointerOver` | - `hover`
- `selectOptions`
- `deselectOptions`
|
+| `pointerUp` | - `click`
- `dblClick`
- `selectOptions`
- `deselectOptions`
|
diff --git a/lib/index.ts b/lib/index.ts
index 12301b65..81390a91 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -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';
@@ -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,
};
@@ -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 = {
diff --git a/lib/rules/no-debug.ts b/lib/rules/no-debug.ts
index 752a1c39..cc2b5e8b 100644
--- a/lib/rules/no-debug.ts
+++ b/lib/rules/no-debug.ts
@@ -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,
@@ -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: {
diff --git a/lib/rules/prefer-user-event.ts b/lib/rules/prefer-user-event.ts
new file mode 100644
index 00000000..4fe4e040
--- /dev/null
+++ b/lib/rules/prefer-user-event.ts
@@ -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 = {
+ 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)({
+ 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)
+ },
+ })
+ }
+ }
+ }
+})
\ No newline at end of file
diff --git a/lib/utils.ts b/lib/utils.ts
index 7af670c8..be015f6b 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -1,3 +1,5 @@
+import { TSESTree } from '@typescript-eslint/experimental-utils';
+
const combineQueries = (variants: string[], methods: string[]) => {
const combinedQueries: string[] = [];
variants.forEach(variant => {
@@ -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 = [
@@ -100,6 +106,7 @@ const ALL_RETURNING_NODES = [
export {
getDocsUrl,
+ hasTestingLibraryImportModule,
SYNC_QUERIES_VARIANTS,
ASYNC_QUERIES_VARIANTS,
ALL_QUERIES_VARIANTS,
diff --git a/tests/__snapshots__/index.test.ts.snap b/tests/__snapshots__/index.test.ts.snap
index 250540c3..da608e52 100644
--- a/tests/__snapshots__/index.test.ts.snap
+++ b/tests/__snapshots__/index.test.ts.snap
@@ -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",
},
}
`;
@@ -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",
},
}
`;
@@ -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",
},
}
`;
@@ -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",
},
}
`;
diff --git a/tests/lib/rules/prefer-user-event.test.ts b/tests/lib/rules/prefer-user-event.test.ts
new file mode 100644
index 00000000..e52cc949
--- /dev/null
+++ b/tests/lib/rules/prefer-user-event.test.ts
@@ -0,0 +1,107 @@
+import { InvalidTestCase, ValidTestCase } from '@typescript-eslint/experimental-utils/dist/ts-eslint'
+import { createRuleTester } from '../test-utils';
+import { LIBRARY_MODULES } from '../../../lib/utils';
+import rule, { RULE_NAME, MessageIds, Options, UserEventMethods, MappingToUserEvent } from '../../../lib/rules/prefer-user-event';
+
+function createScenarioWithImport | InvalidTestCase>(callback: (libraryModule: string, fireEventMethod: string) => T) {
+ return LIBRARY_MODULES.reduce((acc: any, libraryModule) =>
+ acc.concat(
+ Object
+ .keys(MappingToUserEvent)
+ .map((fireEventMethod) => callback(libraryModule, fireEventMethod))
+ )
+ , [])
+}
+
+const ruleTester = createRuleTester();
+
+ruleTester.run(RULE_NAME, rule, {
+ valid: [
+ {
+ code: `
+ import { screen } from '@testing-library/user-event'
+ const element = screen.getByText(foo)
+ `
+ },
+ {
+ code: `
+ const utils = render(baz)
+ const element = utils.getByText(foo)
+ `
+ },
+ ...UserEventMethods.map((userEventMethod) => ({
+ code: `
+ import userEvent from '@testing-library/user-event'
+ const node = document.createElement(elementType)
+ userEvent.${userEventMethod}(foo)
+ `
+ })),
+ ...createScenarioWithImport>((libraryModule: string, fireEventMethod: string) => ({
+ code: `
+ import { fireEvent } from '${libraryModule}'
+ const node = document.createElement(elementType)
+ fireEvent.${fireEventMethod}(foo)
+ `,
+ options: [{ allowedMethods: [fireEventMethod] }]
+ })),
+ ...createScenarioWithImport>((libraryModule: string, fireEventMethod: string) => ({
+ code: `
+ import { fireEvent as fireEventAliased } from '${libraryModule}'
+ const node = document.createElement(elementType)
+ fireEventAliased.${fireEventMethod}(foo)
+ `,
+ options: [{ allowedMethods: [fireEventMethod] }]
+ })),
+ ...createScenarioWithImport>((libraryModule: string, fireEventMethod: string) => ({
+ code: `
+ import * as dom from '${libraryModule}'
+ dom.fireEvent.${fireEventMethod}(foo)
+ `,
+ options: [{ allowedMethods: [fireEventMethod] }]
+ })),
+ ...LIBRARY_MODULES.map((libraryModule) => ({
+ // imported fireEvent and not used,
+ code: `
+ import { fireEvent } from '${libraryModule}'
+ import * as foo from 'someModule'
+ foo.baz()
+ `
+ })),
+ ...LIBRARY_MODULES.map((libraryModule) => ({
+ // imported dom, but not using fireEvent
+ code: `
+ import * as dom from '${libraryModule}'
+ const button = dom.screen.getByRole('button')
+ const foo = dom.screen.container.querySelector('baz')
+ `
+ })),
+ ...LIBRARY_MODULES.map((libraryModule) => ({
+ code: `
+ import { fireEvent as aliasedFireEvent } from '${libraryModule}'
+ function fireEvent() {
+ console.log('foo')
+ }
+ fireEvent()
+ `
+ })),
+ ],
+ invalid: [
+ ...createScenarioWithImport>((libraryModule: string, fireEventMethod: string) => ({
+ code: `
+ import { fireEvent } from '${libraryModule}'
+ const node = document.createElement(elementType)
+ fireEvent.${fireEventMethod}(foo)
+ `,
+ errors: [{
+ messageId: 'preferUserEvent'
+ }]
+ })),
+ ...createScenarioWithImport>((libraryModule: string, fireEventMethod: string) => ({
+ code: `
+ import * as dom from '${libraryModule}'
+ dom.fireEvent.${fireEventMethod}(foo)
+ `,
+ errors: [{ messageId: 'preferUserEvent' }]
+ })),
+ ]
+});
\ No newline at end of file