Skip to content

feat(no-promise-in-fire-event): add new no-promise-in-fire-event rule #180

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 5 commits into from
Jun 21, 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
| [no-debug](docs/rules/no-debug.md) | Disallow the use of `debug` | ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [no-dom-import](docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |
| [no-manual-cleanup](docs/rules/no-manual-cleanup.md) | Disallow the use of `cleanup` | | |
| [no-promise-in-fire-event](docs/rules/no-promise-in-fire-event.md) | Disallow the use of promises passed to a `fireEvent` method | | |
| [no-wait-for-empty-callback](docs/rules/no-wait-for-empty-callback.md) | Disallow empty callbacks for `waitFor` and `waitForElementToBeRemoved` | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [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 | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |
Expand Down
35 changes: 35 additions & 0 deletions docs/rules/no-promise-in-fire-event.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Disallow the use of promises passed to a `fireEvent` method (no-promise-in-fire-event)

The `fireEvent` method expects that a DOM element is passed.

Examples of **incorrect** code for this rule:

```js
import { screen, fireEvent } from '@testing-library/react';

// usage of findBy queries
fireEvent.click(screen.findByRole('button'));

// usage of promises
fireEvent.click(new Promise(jest.fn())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this cover the scenario where the promise is in a variable ? Like

const promise = new Promise(jest.fn())
fireEvent.click(promise)

If so, perhaps we should add it here in the docs

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's great feedback!
It doesn't cover this case, not sure how we can check that :/

```

Examples of **correct** code for this rule:

```js
import { screen, fireEvent } from '@testing-library/react';

// use getBy queries
fireEvent.click(screen.getByRole('button'));

// use awaited findBy queries
fireEvent.click(await screen.findByRole('button'));

// this won't give a linting error, but it will throw a runtime error
const promise = new Promise();
fireEvent.click(promise)`,
```

## Further Reading

- [A Github Issue explaining the problem](https://github.com/testing-library/dom-testing-library/issues/609)
3 changes: 3 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import noDebug from './rules/no-debug';
import noDomImport from './rules/no-dom-import';
import noManualCleanup from './rules/no-manual-cleanup';
import noWaitForEmptyCallback from './rules/no-wait-for-empty-callback';
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';
Expand All @@ -22,6 +23,7 @@ const rules = {
'no-debug': noDebug,
'no-dom-import': noDomImport,
'no-manual-cleanup': noManualCleanup,
'no-promise-in-fire-event': noPromiseInFireEvent,
'no-wait-for-empty-callback': noWaitForEmptyCallback,
'prefer-explicit-assert': preferExplicitAssert,
'prefer-find-by': preferFindBy,
Expand All @@ -34,6 +36,7 @@ const recommendedRules = {
'testing-library/await-async-query': 'error',
'testing-library/await-async-utils': 'error',
'testing-library/no-await-sync-query': 'error',
'testing-library/no-promise-in-fire-event': 'error',
'testing-library/no-wait-for-empty-callback': 'error',
'testing-library/prefer-find-by': 'error',
'testing-library/prefer-screen-queries': 'error',
Expand Down
14 changes: 11 additions & 3 deletions lib/node-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export function isAwaitExpression(
return node && node.type === 'AwaitExpression';
}

export function isNewExpression(
node: TSESTree.Node
): node is TSESTree.NewExpression {
return node && node.type === 'NewExpression';
}

export function isIdentifier(node: TSESTree.Node): node is TSESTree.Identifier {
return node && node.type === 'Identifier';
}
Expand Down Expand Up @@ -103,6 +109,8 @@ export function hasThenProperty(node: TSESTree.Node) {
);
}

export function isArrowFunctionExpression(node: TSESTree.Node): node is TSESTree.ArrowFunctionExpression {
return node && node.type === 'ArrowFunctionExpression'
}
export function isArrowFunctionExpression(
node: TSESTree.Node
): node is TSESTree.ArrowFunctionExpression {
return node && node.type === 'ArrowFunctionExpression';
}
74 changes: 74 additions & 0 deletions lib/rules/no-promise-in-fire-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { TSESTree, ESLintUtils } from '@typescript-eslint/experimental-utils';
import { getDocsUrl, ASYNC_QUERIES_VARIANTS } from '../utils';
import {
isNewExpression,
isIdentifier,
isImportSpecifier,
isCallExpression,
} from '../node-utils';

export const RULE_NAME = 'no-promise-in-fire-event';
export type MessageIds = 'noPromiseInFireEvent';
type Options = [];

export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
name: RULE_NAME,
meta: {
type: 'problem',
docs: {
description:
'Disallow the use of promises passed to a `fireEvent` method',
category: 'Best Practices',
recommended: false,
},
messages: {
noPromiseInFireEvent:
"A promise shouldn't be passed to a `fireEvent` method, instead pass the DOM element",
},
fixable: 'code',
schema: [],
},
defaultOptions: [],

create(context) {
return {
'ImportDeclaration[source.value=/testing-library/]'(
node: TSESTree.ImportDeclaration
) {
const fireEventImportNode = node.specifiers.find(
specifier =>
isImportSpecifier(specifier) &&
specifier.imported &&
'fireEvent' === specifier.imported.name
) as TSESTree.ImportSpecifier;

const { references } = context.getDeclaredVariables(
fireEventImportNode
)[0];

for (const reference of references) {
const referenceNode = reference.identifier;
const callExpression = referenceNode.parent
.parent as TSESTree.CallExpression;
const [element] = callExpression.arguments as TSESTree.Node[];
if (isCallExpression(element) || isNewExpression(element)) {
const methodName = isIdentifier(element.callee)
? element.callee.name
: ((element.callee as TSESTree.MemberExpression)
.property as TSESTree.Identifier).name;

if (
ASYNC_QUERIES_VARIANTS.some(q => methodName.startsWith(q)) ||
methodName === 'Promise'
) {
context.report({
node: element,
messageId: 'noPromiseInFireEvent',
});
}
}
}
},
};
},
});
4 changes: 4 additions & 0 deletions tests/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Object {
"error",
"angular",
],
"testing-library/no-promise-in-fire-event": "error",
"testing-library/no-wait-for-empty-callback": "error",
"testing-library/prefer-find-by": "error",
"testing-library/prefer-screen-queries": "error",
Expand All @@ -35,6 +36,7 @@ Object {
"error",
"react",
],
"testing-library/no-promise-in-fire-event": "error",
"testing-library/no-wait-for-empty-callback": "error",
"testing-library/prefer-find-by": "error",
"testing-library/prefer-screen-queries": "error",
Expand All @@ -51,6 +53,7 @@ Object {
"testing-library/await-async-query": "error",
"testing-library/await-async-utils": "error",
"testing-library/no-await-sync-query": "error",
"testing-library/no-promise-in-fire-event": "error",
"testing-library/no-wait-for-empty-callback": "error",
"testing-library/prefer-find-by": "error",
"testing-library/prefer-screen-queries": "error",
Expand All @@ -73,6 +76,7 @@ Object {
"error",
"vue",
],
"testing-library/no-promise-in-fire-event": "error",
"testing-library/no-wait-for-empty-callback": "error",
"testing-library/prefer-find-by": "error",
"testing-library/prefer-screen-queries": "error",
Expand Down
94 changes: 94 additions & 0 deletions tests/lib/rules/no-promise-in-fire-event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { createRuleTester } from '../test-utils';
import rule, { RULE_NAME } from '../../../lib/rules/no-promise-in-fire-event';

const ruleTester = createRuleTester();

ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: `
import {fireEvent} from '@testing-library/foo';

fireEvent.click(screen.getByRole('button'))
`,
},
{
code: `
import {fireEvent} from '@testing-library/foo';

fireEvent.click(queryByRole('button'))`,
},
{
code: `
import {fireEvent} from '@testing-library/foo';

fireEvent.click(someRef)`,
},
{
code: `fireEvent.click(findByText('submit'))`,
},
{
code: `
import {fireEvent} from '@testing-library/foo';

const promise = new Promise();
fireEvent.click(promise)`,
},
{
code: `
import {fireEvent} from '@testing-library/foo';

fireEvent.click(await screen.findByRole('button'))
`,
},
{
code: `fireEvent.click(Promise())`,
},
],
invalid: [
{
code: `
import {fireEvent} from '@testing-library/foo';

fireEvent.click(screen.findByRole('button'))`,
errors: [
{
messageId: 'noPromiseInFireEvent',
},
],
},
{
code: `
import {fireEvent} from '@testing-library/foo';

fireEvent.click(findByText('submit'))`,
errors: [
{
messageId: 'noPromiseInFireEvent',
},
],
},
{
code: `
import {fireEvent} from '@testing-library/foo';

fireEvent.click(Promise('foo'))`,
errors: [
{
messageId: 'noPromiseInFireEvent',
},
],
},
{
code: `
import {fireEvent} from '@testing-library/foo';

fireEvent.click(new Promise('foo'))`,
errors: [
{
messageId: 'noPromiseInFireEvent',
},
],
},
],
});