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` | | +| `change` | | +| `dblClick` | | +| `input` | | +| `keyDown` | | +| `keyPress` | | +| `keyUp` | | +| `mouseDown` | | +| `mouseEnter` | | +| `mouseLeave` | | +| `mouseMove` | | +| `mouseOut` | | +| `mouseOver` | | +| `mouseUp` | | +| `paste` | | +| `pointerDown` | | +| `pointerEnter` | | +| `pointerLeave` | | +| `pointerMove` | | +| `pointerOut` | | +| `pointerOver` | | +| `pointerUp` | | 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