diff --git a/README.md b/README.md
index f438182c..1bd131a8 100644
--- a/README.md
+++ b/README.md
@@ -193,6 +193,7 @@ To enable this configuration use the `extends` property in your
| [testing-library/no-node-access](docs/rules/no-node-access.md) | Disallow direct Node access | ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [testing-library/no-promise-in-fire-event](docs/rules/no-promise-in-fire-event.md) | Disallow the use of promises passed to a `fireEvent` method | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [testing-library/no-render-in-setup](docs/rules/no-render-in-setup.md) | Disallow the use of `render` in setup functions | | |
+| [testing-library/no-unnecessary-act](docs/rules/no-unnecessary-act.md) | Disallow wrapping Testing Library utils or empty callbacks in `act` | | |
| [testing-library/no-wait-for-empty-callback](docs/rules/no-wait-for-empty-callback.md) | Disallow empty callbacks for `waitFor` and `waitForElementToBeRemoved` | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [testing-library/no-wait-for-multiple-assertions](docs/rules/no-wait-for-multiple-assertions.md) | Disallow the use of multiple expect inside `waitFor` | | |
| [testing-library/no-wait-for-side-effects](docs/rules/no-wait-for-side-effects.md) | Disallow the use of side effects inside `waitFor` | | |
@@ -200,8 +201,8 @@ To enable this configuration use the `extends` property in your
| [testing-library/prefer-explicit-assert](docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | |
| [testing-library/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][] |
| [testing-library/prefer-presence-queries](docs/rules/prefer-presence-queries.md) | Enforce specific queries when checking element is present or not | | |
-| [testing-library/prefer-user-event](docs/rules/prefer-user-event.md) | Suggest using `userEvent` library instead of `fireEvent` for simulating user interaction | | |
| [testing-library/prefer-screen-queries](docs/rules/prefer-screen-queries.md) | Suggest using screen while using queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
+| [testing-library/prefer-user-event](docs/rules/prefer-user-event.md) | Suggest using `userEvent` library instead of `fireEvent` for simulating user interaction | | |
| [testing-library/prefer-wait-for](docs/rules/prefer-wait-for.md) | Use `waitFor` instead of deprecated wait methods | | ![fixable-badge][] |
| [testing-library/render-result-naming-convention](docs/rules/render-result-naming-convention.md) | Enforce a valid naming for return value from `render` | ![angular-badge][] ![react-badge][] ![vue-badge][] | |
diff --git a/docs/rules/no-unnecessary-act.md b/docs/rules/no-unnecessary-act.md
new file mode 100644
index 00000000..065b6c10
--- /dev/null
+++ b/docs/rules/no-unnecessary-act.md
@@ -0,0 +1,94 @@
+# Disallow wrapping Testing Library utils or empty callbacks in `act` (`testing-library/no-unnecessary-act`)
+
+> ⚠️ The `act` method is only available on the following Testing Library packages:
+>
+> - `@testing-library/react` (supported by this plugin)
+> - `@testing-library/preact` (not supported yet by this plugin)
+> - `@testing-library/svelte` (not supported yet by this plugin)
+
+## Rule Details
+
+This rule aims to avoid the usage of `act` to wrap Testing Library utils just to silence "not wrapped in act(...)" warnings.
+
+All Testing Library utils are already wrapped in `act`. Most of the time, if you're seeing an `act` warning, it's not just something to be silenced, but it's actually telling you that something unexpected is happening in your test.
+
+Additionally, wrapping empty callbacks in `act` is also an incorrect way of silencing "not wrapped in act(...)" warnings.
+
+Code violations reported by this rule will pinpoint those unnecessary `act`, helping to understand when `act` actually is necessary.
+
+Example of **incorrect** code for this rule:
+
+```js
+// ❌ wrapping things related to Testing Library in `act` is incorrect
+import {
+ act,
+ render,
+ screen,
+ waitFor,
+ fireEvent,
+} from '@testing-library/react';
+// ^ act imported from 'react-dom/test-utils' will be reported too
+import userEvent from '@testing-library/user-event';
+
+// ...
+
+act(() => {
+ render();
+});
+
+await act(async () => waitFor(() => {}));
+
+act(() => screen.getByRole('button'));
+
+act(() => {
+ fireEvent.click(element);
+});
+
+act(() => {
+ userEvent.click(element);
+});
+```
+
+```js
+// ❌ wrapping empty callbacks in `act` is incorrect
+import { act } from '@testing-library/react';
+// ^ act imported from 'react-dom/test-utils' will be reported too
+import userEvent from '@testing-library/user-event';
+
+// ...
+
+act(() => {});
+
+await act(async () => {});
+```
+
+Examples of **correct** code for this rule:
+
+```js
+// ✅ wrapping things not related to Testing Library in `act` is correct
+import { act } from '@testing-library/react';
+import { stuffThatDoesNotUseRTL } from 'somwhere-else';
+
+// ...
+
+act(() => {
+ stuffThatDoesNotUseRTL();
+});
+```
+
+```js
+// ✅ wrapping both things related and not related to Testing Library in `act` is correct
+import { act, screen } from '@testing-library/react';
+import { stuffThatDoesNotUseRTL } from 'somwhere-else';
+
+await act(async () => {
+ await screen.findByRole('button');
+ stuffThatDoesNotUseRTL();
+});
+```
+
+## Further Reading
+
+- [Inspiration for this rule](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#wrapping-things-in-act-unnecessarily)
+- [Fix the "not wrapped in act(...)" warning](https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning)
+- [About React Testing Library `act`](https://testing-library.com/docs/react-testing-library/api/#act)
diff --git a/lib/create-testing-library-rule/detect-testing-library-utils.ts b/lib/create-testing-library-rule/detect-testing-library-utils.ts
index 3d6662be..6a85322f 100644
--- a/lib/create-testing-library-rule/detect-testing-library-utils.ts
+++ b/lib/create-testing-library-rule/detect-testing-library-utils.ts
@@ -5,6 +5,8 @@ import {
} from '@typescript-eslint/experimental-utils';
import {
+ findClosestVariableDeclaratorNode,
+ findImportSpecifier,
getAssertNodeInfo,
getDeepestIdentifierNode,
getImportModuleName,
@@ -12,14 +14,12 @@ import {
getReferenceNode,
hasImportMatch,
ImportModuleNode,
+ isCallExpression,
isImportDeclaration,
isImportDefaultSpecifier,
- isImportNamespaceSpecifier,
isImportSpecifier,
isLiteral,
isMemberExpression,
- isObjectPattern,
- isProperty,
} from '../node-utils';
import {
ABSENCE_MATCHERS,
@@ -83,7 +83,7 @@ type IsDebugUtilFn = (identifierNode: TSESTree.Identifier) => boolean;
type IsPresenceAssertFn = (node: TSESTree.MemberExpression) => boolean;
type IsAbsenceAssertFn = (node: TSESTree.MemberExpression) => boolean;
type CanReportErrorsFn = () => boolean;
-type FindImportedUtilSpecifierFn = (
+type FindImportedTestingLibraryUtilSpecifierFn = (
specifierName: string
) => TSESTree.ImportClause | TSESTree.Identifier | undefined;
type IsNodeComingFromTestingLibraryFn = (
@@ -96,6 +96,7 @@ export interface DetectionHelpers {
getTestingLibraryImportName: GetTestingLibraryImportNameFn;
getCustomModuleImportName: GetCustomModuleImportNameFn;
isTestingLibraryImported: IsTestingLibraryImportedFn;
+ isTestingLibraryUtil: (node: TSESTree.Identifier) => boolean;
isGetQueryVariant: IsGetQueryVariantFn;
isQueryQueryVariant: IsQueryQueryVariantFn;
isFindQueryVariant: IsFindQueryVariantFn;
@@ -112,14 +113,16 @@ export interface DetectionHelpers {
isRenderUtil: IsRenderUtilFn;
isRenderVariableDeclarator: IsRenderVariableDeclaratorFn;
isDebugUtil: IsDebugUtilFn;
+ isActUtil: (node: TSESTree.Identifier) => boolean;
isPresenceAssert: IsPresenceAssertFn;
isAbsenceAssert: IsAbsenceAssertFn;
canReportErrors: CanReportErrorsFn;
- findImportedUtilSpecifier: FindImportedUtilSpecifierFn;
+ findImportedTestingLibraryUtilSpecifier: FindImportedTestingLibraryUtilSpecifierFn;
isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn;
}
const USER_EVENT_PACKAGE = '@testing-library/user-event';
+const REACT_DOM_TEST_UTILS_PACKAGE = 'react-dom/test-utils';
const FIRE_EVENT_NAME = 'fireEvent';
const USER_EVENT_NAME = 'userEvent';
const RENDER_NAME = 'render';
@@ -153,6 +156,7 @@ export function detectTestingLibraryUtils<
let importedTestingLibraryNode: ImportModuleNode | null = null;
let importedCustomModuleNode: ImportModuleNode | null = null;
let importedUserEventLibraryNode: ImportModuleNode | null = null;
+ let importedReactDomTestUtilsNode: ImportModuleNode | null = null;
// Init options based on shared ESLint settings
const customModuleSetting =
@@ -172,9 +176,9 @@ export function detectTestingLibraryUtils<
* - it's imported from valid Testing Library module (depends on aggressive
* reporting)
*/
- function isTestingLibraryUtil(
+ function isPotentialTestingLibraryFunction(
node: TSESTree.Identifier,
- isUtilCallback: (
+ isPotentialFunctionCallback: (
identifierNodeName: string,
originalNodeName?: string
) => boolean
@@ -190,7 +194,7 @@ export function detectTestingLibraryUtils<
return false;
}
- const importedUtilSpecifier = getImportedUtilSpecifier(
+ const importedUtilSpecifier = getTestingLibraryImportedUtilSpecifier(
referenceNodeIdentifier
);
@@ -200,7 +204,7 @@ export function detectTestingLibraryUtils<
? importedUtilSpecifier.imported.name
: undefined;
- if (!isUtilCallback(node.name, originalNodeName)) {
+ if (!isPotentialFunctionCallback(node.name, originalNodeName)) {
return false;
}
@@ -412,7 +416,7 @@ export function detectTestingLibraryUtils<
* coming from Testing Library will be considered as valid.
*/
const isAsyncUtil: IsAsyncUtilFn = (node, validNames = ASYNC_UTILS) => {
- return isTestingLibraryUtil(
+ return isPotentialTestingLibraryFunction(
node,
(identifierNodeName, originalNodeName) => {
return (
@@ -430,7 +434,7 @@ export function detectTestingLibraryUtils<
* Not to be confused with {@link isFireEventMethod}
*/
const isFireEventUtil = (node: TSESTree.Identifier): boolean => {
- return isTestingLibraryUtil(
+ return isPotentialTestingLibraryFunction(
node,
(identifierNodeName, originalNodeName) => {
return [identifierNodeName, originalNodeName].includes('fireEvent');
@@ -464,7 +468,9 @@ export function detectTestingLibraryUtils<
* Determines whether a given node is fireEvent method or not
*/
const isFireEventMethod: IsFireEventMethodFn = (node) => {
- const fireEventUtil = findImportedUtilSpecifier(FIRE_EVENT_NAME);
+ const fireEventUtil = findImportedTestingLibraryUtilSpecifier(
+ FIRE_EVENT_NAME
+ );
let fireEventUtilName: string | undefined;
if (fireEventUtil) {
@@ -570,17 +576,21 @@ export function detectTestingLibraryUtils<
* only those nodes coming from Testing Library will be considered as valid.
*/
const isRenderUtil: IsRenderUtilFn = (node) =>
- isTestingLibraryUtil(node, (identifierNodeName, originalNodeName) => {
- if (isAggressiveRenderReportingEnabled()) {
- return identifierNodeName.toLowerCase().includes(RENDER_NAME);
- }
+ isPotentialTestingLibraryFunction(
+ node,
+ (identifierNodeName, originalNodeName) => {
+ if (isAggressiveRenderReportingEnabled()) {
+ return identifierNodeName.toLowerCase().includes(RENDER_NAME);
+ }
- return [RENDER_NAME, ...getCustomRenders()].some(
- (validRenderName) =>
- validRenderName === identifierNodeName ||
- (Boolean(originalNodeName) && validRenderName === originalNodeName)
- );
- });
+ return [RENDER_NAME, ...getCustomRenders()].some(
+ (validRenderName) =>
+ validRenderName === identifierNodeName ||
+ (Boolean(originalNodeName) &&
+ validRenderName === originalNodeName)
+ );
+ }
+ );
const isRenderVariableDeclarator: IsRenderVariableDeclaratorFn = (node) => {
if (!node.init) {
@@ -603,7 +613,7 @@ export function detectTestingLibraryUtils<
return (
!isBuiltInConsole &&
- isTestingLibraryUtil(
+ isPotentialTestingLibraryFunction(
identifierNode,
(identifierNodeName, originalNodeName) => {
return [identifierNodeName, originalNodeName]
@@ -614,6 +624,91 @@ export function detectTestingLibraryUtils<
);
};
+ /**
+ * Determines whether a given node is some reportable `act` util.
+ *
+ * An `act` is reportable if some of these conditions is met:
+ * - it's related to Testing Library module (this depends on Aggressive Reporting)
+ * - it's related to React DOM Test Utils
+ */
+ const isActUtil = (node: TSESTree.Identifier): boolean => {
+ const isTestingLibraryAct = isPotentialTestingLibraryFunction(
+ node,
+ (identifierNodeName, originalNodeName) => {
+ return [identifierNodeName, originalNodeName]
+ .filter(Boolean)
+ .includes('act');
+ }
+ );
+
+ const isReactDomTestUtilsAct = (() => {
+ if (!importedReactDomTestUtilsNode) {
+ return false;
+ }
+ const referenceNode = getReferenceNode(node);
+ const referenceNodeIdentifier = getPropertyIdentifierNode(
+ referenceNode
+ );
+ if (!referenceNodeIdentifier) {
+ return false;
+ }
+
+ const importedUtilSpecifier = findImportSpecifier(
+ node.name,
+ importedReactDomTestUtilsNode
+ );
+ if (!importedUtilSpecifier) {
+ return false;
+ }
+
+ const importDeclaration = (() => {
+ if (isImportDeclaration(importedUtilSpecifier.parent)) {
+ return importedUtilSpecifier.parent;
+ }
+
+ const variableDeclarator = findClosestVariableDeclaratorNode(
+ importedUtilSpecifier
+ );
+
+ if (isCallExpression(variableDeclarator?.init)) {
+ return variableDeclarator?.init;
+ }
+
+ return undefined;
+ })();
+ if (!importDeclaration) {
+ return false;
+ }
+
+ const importDeclarationName = getImportModuleName(importDeclaration);
+ if (!importDeclarationName) {
+ return false;
+ }
+
+ if (importDeclarationName !== REACT_DOM_TEST_UTILS_PACKAGE) {
+ return false;
+ }
+
+ return hasImportMatch(
+ importedUtilSpecifier,
+ referenceNodeIdentifier.name
+ );
+ })();
+
+ return isTestingLibraryAct || isReactDomTestUtilsAct;
+ };
+
+ const isTestingLibraryUtil = (node: TSESTree.Identifier): boolean => {
+ return (
+ isAsyncUtil(node) ||
+ isQuery(node) ||
+ isRenderUtil(node) ||
+ isFireEventMethod(node) ||
+ isUserEventMethod(node) ||
+ isActUtil(node)
+ );
+ };
+
/**
* Determines whether a given MemberExpression node is a presence assert
*
@@ -653,60 +748,18 @@ export function detectTestingLibraryUtils<
};
/**
- * Gets a string and verifies if it was imported/required by Testing Library
- * related module.
+ * Finds the import util specifier related to Testing Library for a given name.
*/
- const findImportedUtilSpecifier: FindImportedUtilSpecifierFn = (
+ const findImportedTestingLibraryUtilSpecifier: FindImportedTestingLibraryUtilSpecifierFn = (
specifierName
- ) => {
+ ): TSESTree.ImportClause | TSESTree.Identifier | undefined => {
const node = getCustomModuleImportNode() ?? getTestingLibraryImportNode();
if (!node) {
return undefined;
}
- if (isImportDeclaration(node)) {
- const namedExport = node.specifiers.find((n) => {
- return (
- isImportSpecifier(n) &&
- [n.imported.name, n.local.name].includes(specifierName)
- );
- });
-
- // it is "import { foo [as alias] } from 'baz'""
- if (namedExport) {
- return namedExport;
- }
-
- // it could be "import * as rtl from 'baz'"
- return node.specifiers.find((n) => isImportNamespaceSpecifier(n));
- } else {
- if (!ASTUtils.isVariableDeclarator(node.parent)) {
- return undefined;
- }
- const requireNode = node.parent;
-
- if (ASTUtils.isIdentifier(requireNode.id)) {
- // this is const rtl = require('foo')
- return requireNode.id;
- }
-
- // this should be const { something } = require('foo')
- if (!isObjectPattern(requireNode.id)) {
- return undefined;
- }
-
- const property = requireNode.id.properties.find(
- (n) =>
- isProperty(n) &&
- ASTUtils.isIdentifier(n.key) &&
- n.key.name === specifierName
- );
- if (!property) {
- return undefined;
- }
- return (property as TSESTree.Property).key as TSESTree.Identifier;
- }
+ return findImportSpecifier(specifierName, node);
};
const findImportedUserEventSpecifier: () => TSESTree.Identifier | null = () => {
@@ -740,7 +793,7 @@ export function detectTestingLibraryUtils<
return null;
};
- const getImportedUtilSpecifier = (
+ const getTestingLibraryImportedUtilSpecifier = (
node: TSESTree.MemberExpression | TSESTree.Identifier
): TSESTree.ImportClause | TSESTree.Identifier | undefined => {
const identifierName: string | undefined = getPropertyIdentifierNode(node)
@@ -750,7 +803,7 @@ export function detectTestingLibraryUtils<
return undefined;
}
- return findImportedUtilSpecifier(identifierName);
+ return findImportedTestingLibraryUtilSpecifier(identifierName);
};
/**
@@ -769,12 +822,43 @@ export function detectTestingLibraryUtils<
const isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn = (
node
) => {
- const importNode = getImportedUtilSpecifier(node);
+ const importNode = getTestingLibraryImportedUtilSpecifier(node);
if (!importNode) {
return false;
}
+ const referenceNode = getReferenceNode(node);
+ const referenceNodeIdentifier = getPropertyIdentifierNode(referenceNode);
+ if (!referenceNodeIdentifier) {
+ return false;
+ }
+
+ const importDeclaration = (() => {
+ if (isImportDeclaration(importNode.parent)) {
+ return importNode.parent;
+ }
+
+ const variableDeclarator = findClosestVariableDeclaratorNode(
+ importNode
+ );
+
+ if (isCallExpression(variableDeclarator?.init)) {
+ return variableDeclarator?.init;
+ }
+
+ return undefined;
+ })();
+
+ if (!importDeclaration) {
+ return false;
+ }
+
+ const importDeclarationName = getImportModuleName(importDeclaration);
+ if (!importDeclarationName) {
+ return false;
+ }
+
const identifierName: string | undefined = getPropertyIdentifierNode(node)
?.name;
@@ -782,7 +866,13 @@ export function detectTestingLibraryUtils<
return false;
}
- return hasImportMatch(importNode, identifierName);
+ const hasImportElementMatch = hasImportMatch(importNode, identifierName);
+ const hasImportModuleMatch =
+ /testing-library/g.test(importDeclarationName) ||
+ (typeof customModuleSetting === 'string' &&
+ importDeclarationName.endsWith(customModuleSetting));
+
+ return hasImportElementMatch && hasImportModuleMatch;
};
const helpers: DetectionHelpers = {
@@ -791,6 +881,7 @@ export function detectTestingLibraryUtils<
getTestingLibraryImportName,
getCustomModuleImportName,
isTestingLibraryImported,
+ isTestingLibraryUtil,
isGetQueryVariant,
isQueryQueryVariant,
isFindQueryVariant,
@@ -807,10 +898,11 @@ export function detectTestingLibraryUtils<
isRenderUtil,
isRenderVariableDeclarator,
isDebugUtil,
+ isActUtil,
isPresenceAssert,
isAbsenceAssert,
canReportErrors,
- findImportedUtilSpecifier,
+ findImportedTestingLibraryUtilSpecifier,
isNodeComingFromTestingLibrary,
};
@@ -824,11 +916,14 @@ export function detectTestingLibraryUtils<
* parts of the file.
*/
ImportDeclaration(node: TSESTree.ImportDeclaration) {
+ if (typeof node.source.value !== 'string') {
+ return;
+ }
// check only if testing library import not found yet so we avoid
// to override importedTestingLibraryNode after it's found
if (
!importedTestingLibraryNode &&
- /testing-library/g.test(node.source.value as string)
+ /testing-library/g.test(node.source.value)
) {
importedTestingLibraryNode = node;
}
@@ -839,7 +934,7 @@ export function detectTestingLibraryUtils<
if (
customModule &&
!importedCustomModuleNode &&
- String(node.source.value).endsWith(customModule)
+ node.source.value.endsWith(customModule)
) {
importedCustomModuleNode = node;
}
@@ -848,10 +943,19 @@ export function detectTestingLibraryUtils<
// to override importedUserEventLibraryNode after it's found
if (
!importedUserEventLibraryNode &&
- String(node.source.value) === USER_EVENT_PACKAGE
+ node.source.value === USER_EVENT_PACKAGE
) {
importedUserEventLibraryNode = node;
}
+
+ // check only if react-dom/test-utils import not found yet so we avoid
+ // to override importedReactDomTestUtilsNode after it's found
+ if (
+ !importedUserEventLibraryNode &&
+ node.source.value === REACT_DOM_TEST_UTILS_PACKAGE
+ ) {
+ importedReactDomTestUtilsNode = node;
+ }
},
// Check if Testing Library related modules are loaded with required.
@@ -898,6 +1002,18 @@ export function detectTestingLibraryUtils<
) {
importedUserEventLibraryNode = callExpression;
}
+
+ if (
+ !importedReactDomTestUtilsNode &&
+ args.some(
+ (arg) =>
+ isLiteral(arg) &&
+ typeof arg.value === 'string' &&
+ arg.value === REACT_DOM_TEST_UTILS_PACKAGE
+ )
+ ) {
+ importedReactDomTestUtilsNode = callExpression;
+ }
},
};
diff --git a/lib/node-utils/index.ts b/lib/node-utils/index.ts
index caa94108..62e7dc3d 100644
--- a/lib/node-utils/index.ts
+++ b/lib/node-utils/index.ts
@@ -10,13 +10,19 @@ import { RuleContext } from '@typescript-eslint/experimental-utils/dist/ts-eslin
import {
isArrayExpression,
isArrowFunctionExpression,
+ isAssignmentExpression,
isBlockStatement,
isCallExpression,
isExpressionStatement,
isImportDeclaration,
+ isImportNamespaceSpecifier,
+ isImportSpecifier,
isLiteral,
isMemberExpression,
+ isObjectPattern,
+ isProperty,
isReturnStatement,
+ isVariableDeclaration,
} from './is-node-of-type';
export * from './is-node-of-type';
@@ -76,6 +82,23 @@ export function findClosestCallExpressionNode(
return findClosestCallExpressionNode(node.parent, shouldRestrictInnerScope);
}
+export function findClosestVariableDeclaratorNode(
+ node: TSESTree.Node | undefined
+): TSESTree.VariableDeclarator | null {
+ if (!node) {
+ return null;
+ }
+
+ if (ASTUtils.isVariableDeclarator(node)) {
+ return node;
+ }
+
+ return findClosestVariableDeclaratorNode(node.parent);
+}
+
+/**
+ * TODO: remove this one in favor of {@link findClosestCallExpressionNode}
+ */
export function findClosestCallNode(
node: TSESTree.Node,
name: string
@@ -510,3 +533,115 @@ export function hasImportMatch(
return importNode.local.name === identifierName;
}
+
+export function getStatementCallExpression(
+ statement: TSESTree.Statement
+): TSESTree.CallExpression | undefined {
+ if (isExpressionStatement(statement)) {
+ const { expression } = statement;
+ if (isCallExpression(expression)) {
+ return expression;
+ }
+
+ if (
+ ASTUtils.isAwaitExpression(expression) &&
+ isCallExpression(expression.argument)
+ ) {
+ return expression.argument;
+ }
+
+ if (isAssignmentExpression(expression)) {
+ if (isCallExpression(expression.right)) {
+ return expression.right;
+ }
+
+ if (
+ ASTUtils.isAwaitExpression(expression.right) &&
+ isCallExpression(expression.right.argument)
+ ) {
+ return expression.right.argument;
+ }
+ }
+ }
+
+ if (isReturnStatement(statement) && isCallExpression(statement.argument)) {
+ return statement.argument;
+ }
+
+ if (isVariableDeclaration(statement)) {
+ for (const declaration of statement.declarations) {
+ if (isCallExpression(declaration.init)) {
+ return declaration.init;
+ }
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Determines whether a given function node is considered as empty function or not.
+ *
+ * A function is considered empty if its body is empty.
+ *
+ * Note that comments don't affect the check.
+ *
+ * If node given is not a function, `false` will be returned.
+ */
+export function isEmptyFunction(node: TSESTree.Node): boolean | undefined {
+ if (ASTUtils.isFunction(node) && isBlockStatement(node.body)) {
+ return node.body.body.length === 0;
+ }
+
+ return false;
+}
+
+/**
+ * Finds the import specifier matching a given name for a given import module node.
+ */
+export function findImportSpecifier(
+ specifierName: string,
+ node: ImportModuleNode
+): TSESTree.ImportClause | TSESTree.Identifier | undefined {
+ if (isImportDeclaration(node)) {
+ const namedExport = node.specifiers.find((n) => {
+ return (
+ isImportSpecifier(n) &&
+ [n.imported.name, n.local.name].includes(specifierName)
+ );
+ });
+
+ // it is "import { foo [as alias] } from 'baz'"
+ if (namedExport) {
+ return namedExport;
+ }
+
+ // it could be "import * as rtl from 'baz'"
+ return node.specifiers.find((n) => isImportNamespaceSpecifier(n));
+ } else {
+ if (!ASTUtils.isVariableDeclarator(node.parent)) {
+ return undefined;
+ }
+ const requireNode = node.parent;
+
+ if (ASTUtils.isIdentifier(requireNode.id)) {
+ // this is const rtl = require('foo')
+ return requireNode.id;
+ }
+
+ // this should be const { something } = require('foo')
+ if (!isObjectPattern(requireNode.id)) {
+ return undefined;
+ }
+
+ const property = requireNode.id.properties.find(
+ (n) =>
+ isProperty(n) &&
+ ASTUtils.isIdentifier(n.key) &&
+ n.key.name === specifierName
+ );
+ if (!property) {
+ return undefined;
+ }
+ return (property as TSESTree.Property).key as TSESTree.Identifier;
+ }
+}
diff --git a/lib/rules/no-unnecessary-act.ts b/lib/rules/no-unnecessary-act.ts
new file mode 100644
index 00000000..152dd226
--- /dev/null
+++ b/lib/rules/no-unnecessary-act.ts
@@ -0,0 +1,141 @@
+import { TSESTree } from '@typescript-eslint/experimental-utils';
+import { createTestingLibraryRule } from '../create-testing-library-rule';
+import {
+ getDeepestIdentifierNode,
+ getStatementCallExpression,
+ isEmptyFunction,
+} from '../node-utils';
+
+export const RULE_NAME = 'no-unnecessary-act';
+export type MessageIds =
+ | 'noUnnecessaryActTestingLibraryUtil'
+ | 'noUnnecessaryActEmptyFunction';
+
+export default createTestingLibraryRule<[], MessageIds>({
+ name: RULE_NAME,
+ meta: {
+ type: 'problem',
+ docs: {
+ description:
+ 'Disallow wrapping Testing Library utils or empty callbacks in `act`',
+ category: 'Possible Errors',
+ recommendedConfig: {
+ dom: false,
+ angular: false,
+ react: false,
+ vue: false,
+ },
+ },
+ messages: {
+ noUnnecessaryActTestingLibraryUtil:
+ 'Avoid wrapping Testing Library util calls in `act`',
+ noUnnecessaryActEmptyFunction: 'Avoid wrapping empty function in `act`',
+ },
+ schema: [],
+ },
+ defaultOptions: [],
+
+ create(context, _, helpers) {
+ /**
+ * Determines whether some call is non Testing Library related for a given list of statements.
+ */
+ function hasSomeNonTestingLibraryCall(
+ statements: TSESTree.Statement[]
+ ): boolean {
+ return statements.some((statement) => {
+ const callExpression = getStatementCallExpression(statement);
+
+ if (!callExpression) {
+ return false;
+ }
+
+ const identifier = getDeepestIdentifierNode(callExpression);
+
+ if (!identifier) {
+ return false;
+ }
+
+ return !helpers.isTestingLibraryUtil(identifier);
+ });
+ }
+
+ function checkNoUnnecessaryActFromBlockStatement(
+ blockStatementNode: TSESTree.BlockStatement
+ ) {
+ const functionNode = blockStatementNode?.parent as
+ | TSESTree.FunctionExpression
+ | TSESTree.ArrowFunctionExpression
+ | undefined;
+ const callExpressionNode = functionNode?.parent as
+ | TSESTree.CallExpression
+ | undefined;
+
+ if (!callExpressionNode || !functionNode) {
+ return;
+ }
+
+ const identifierNode = getDeepestIdentifierNode(callExpressionNode);
+ if (!identifierNode) {
+ return;
+ }
+
+ if (!helpers.isActUtil(identifierNode)) {
+ return;
+ }
+
+ if (isEmptyFunction(functionNode)) {
+ context.report({
+ node: identifierNode,
+ messageId: 'noUnnecessaryActEmptyFunction',
+ });
+ } else if (!hasSomeNonTestingLibraryCall(blockStatementNode.body)) {
+ context.report({
+ node: identifierNode,
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ });
+ }
+ }
+
+ function checkNoUnnecessaryActFromImplicitReturn(
+ node: TSESTree.CallExpression
+ ) {
+ const nodeIdentifier = getDeepestIdentifierNode(node);
+
+ if (!nodeIdentifier) {
+ return;
+ }
+
+ const parentCallExpression = node?.parent?.parent as
+ | TSESTree.CallExpression
+ | undefined;
+
+ if (!parentCallExpression) {
+ return;
+ }
+
+ const identifierNode = getDeepestIdentifierNode(parentCallExpression);
+ if (!identifierNode) {
+ return;
+ }
+
+ if (!helpers.isActUtil(identifierNode)) {
+ return;
+ }
+
+ if (!helpers.isTestingLibraryUtil(nodeIdentifier)) {
+ return;
+ }
+
+ context.report({
+ node: identifierNode,
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ });
+ }
+
+ return {
+ 'CallExpression > ArrowFunctionExpression > BlockStatement': checkNoUnnecessaryActFromBlockStatement,
+ 'CallExpression > FunctionExpression > BlockStatement': checkNoUnnecessaryActFromBlockStatement,
+ 'CallExpression > ArrowFunctionExpression > CallExpression': checkNoUnnecessaryActFromImplicitReturn,
+ };
+ },
+});
diff --git a/lib/rules/no-wait-for-empty-callback.ts b/lib/rules/no-wait-for-empty-callback.ts
index 97e2ac64..0ca71193 100644
--- a/lib/rules/no-wait-for-empty-callback.ts
+++ b/lib/rules/no-wait-for-empty-callback.ts
@@ -1,8 +1,8 @@
import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils';
import {
getPropertyIdentifierNode,
- isBlockStatement,
isCallExpression,
+ isEmptyFunction,
} from '../node-utils';
import { createTestingLibraryRule } from '../create-testing-library-rule';
@@ -57,8 +57,7 @@ export default createTestingLibraryRule({
}
if (
- isBlockStatement(node.body) &&
- node.body.body.length === 0 &&
+ isEmptyFunction(node) &&
isCallExpression(node.parent) &&
ASTUtils.isIdentifier(node.parent.callee)
) {
diff --git a/tests/index.test.ts b/tests/index.test.ts
index 2f9808cb..2ba1adc7 100644
--- a/tests/index.test.ts
+++ b/tests/index.test.ts
@@ -6,7 +6,7 @@ import plugin from '../lib';
const generateConfigs = () => exec(`npm run generate:configs`);
-const numberOfRules = 24;
+const numberOfRules = 25;
const ruleNames = Object.keys(plugin.rules);
// eslint-disable-next-line jest/expect-expect
diff --git a/tests/lib/rules/no-unnecessary-act.test.ts b/tests/lib/rules/no-unnecessary-act.test.ts
new file mode 100644
index 00000000..b5bb7992
--- /dev/null
+++ b/tests/lib/rules/no-unnecessary-act.test.ts
@@ -0,0 +1,803 @@
+import { createRuleTester } from '../test-utils';
+import rule, { RULE_NAME } from '../../../lib/rules/no-unnecessary-act';
+
+const ruleTester = createRuleTester();
+
+/**
+ * - AGR stands for Aggressive Reporting
+ * - RTL stands for React Testing Library (@testing-library/react)
+ * - RTU stands for React Test Utils (react-dom/test-utils)
+ */
+ruleTester.run(RULE_NAME, rule, {
+ valid: [
+ {
+ code: `// case: RTL act wrapping non-RTL calls
+ import { act } from '@testing-library/react'
+
+ test('valid case', async () => {
+ act(() => {
+ stuffThatDoesNotUseRTL();
+ });
+
+ act(function() {
+ a = stuffThatDoesNotUseRTL();
+ });
+
+ act(function() {
+ a = await stuffThatDoesNotUseRTL();
+ });
+
+ await act(async () => {
+ await stuffThatDoesNotUseRTL();
+ });
+
+ act(function() {
+ stuffThatDoesNotUseRTL();
+ const a = foo();
+ });
+
+ act(function() {
+ return stuffThatDoesNotUseRTL();
+ });
+
+ act(() => stuffThatDoesNotUseRTL());
+
+ act(() => stuffThatDoesNotUseRTL()).then(() => {})
+ act(stuffThatDoesNotUseRTL().then(() => {}))
+ });
+ `,
+ },
+ {
+ code: `// case: RTU act wrapping non-RTL
+ import { act } from 'react-dom/test-utils'
+
+ test('valid case', async () => {
+ act(() => {
+ stuffThatDoesNotUseRTL();
+ });
+
+ await act(async () => {
+ stuffThatDoesNotUseRTL();
+ });
+
+ act(function() {
+ stuffThatDoesNotUseRTL();
+ });
+
+ act(function() {
+ return stuffThatDoesNotUseRTL();
+ });
+
+ act(() => stuffThatDoesNotUseRTL());
+ });
+ `,
+ },
+ {
+ settings: {
+ 'testing-library/utils-module': 'test-utils',
+ },
+ code: `// case: RTL act wrapping non-RTL - AGR disabled
+ import { act } from '@testing-library/react'
+ import { waitFor } from 'somewhere-else'
+
+ test('valid case', async () => {
+ act(() => {
+ waitFor();
+ });
+
+ await act(async () => {
+ waitFor();
+ });
+
+ act(function() {
+ waitFor();
+ });
+
+ act(function() {
+ return waitFor();
+ });
+
+ act(() => waitFor());
+ });
+ `,
+ },
+ {
+ code: `// case: RTL act wrapping both RTL and non-RTL calls
+ import { act, render, waitFor } from '@testing-library/react'
+
+ test('valid case', async () => {
+ act(() => {
+ render(element);
+ stuffThatDoesNotUseRTL();
+ });
+
+ await act(async () => {
+ waitFor();
+ stuffThatDoesNotUseRTL();
+ });
+
+ act(function() {
+ waitFor();
+ stuffThatDoesNotUseRTL();
+ });
+ });
+ `,
+ },
+ {
+ settings: {
+ 'testing-library/utils-module': 'test-utils',
+ },
+ code: `// case: non-RTL act wrapping RTL - AGR disabled
+ import { act } from 'somewhere-else'
+ import { waitFor } from '@testing-library/react'
+
+ test('valid case', async () => {
+ act(() => {
+ waitFor();
+ });
+
+ await act(async () => {
+ waitFor();
+ });
+
+ act(function() {
+ waitFor();
+ });
+
+ act(function() {
+ return waitFor();
+ });
+
+ act(() => waitFor());
+
+ act(() => {})
+ await act(async () => {})
+ act(function() {})
+ });
+ `,
+ },
+ ],
+ invalid: [
+ // cases for act related to React Testing Library
+ {
+ code: `// case: RTL act wrapping RTL calls - callbacks with body (BlockStatement)
+ import { act, fireEvent, screen, render, waitFor, waitForElementToBeRemoved } from '@testing-library/react'
+ import userEvent from '@testing-library/user-event'
+
+ test('invalid case', async () => {
+ act(() => {
+ fireEvent.click(el);
+ });
+
+ await act(async () => {
+ waitFor(() => {});
+ });
+
+ await act(async () => {
+ waitForElementToBeRemoved(el);
+ });
+
+ act(function() {
+ const blah = screen.getByText('blah');
+ });
+
+ act(function() {
+ render(something);
+ });
+
+ await act(() => {
+ const button = findByRole('button')
+ });
+
+ act(() => {
+ userEvent.click(el)
+ });
+
+ act(() => {
+ waitFor();
+ const element = screen.getByText('blah');
+ userEvent.click(element)
+ });
+ });
+ `,
+ errors: [
+ { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 6, column: 9 },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 10,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 14,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 18,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 22,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 26,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 30,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 34,
+ column: 9,
+ },
+ ],
+ },
+ {
+ settings: {
+ 'testing-library/utils-module': 'test-utils',
+ },
+ code: `// case: RTL act wrapping RTL calls - callbacks with body (BlockStatement) - AGR disabled
+ import { act, fireEvent, screen, render, waitFor, waitForElementToBeRemoved } from 'test-utils'
+ import userEvent from '@testing-library/user-event'
+
+ test('invalid case', async () => {
+ act(() => {
+ fireEvent.click(el);
+ });
+
+ await act(async () => {
+ waitFor(() => {});
+ });
+
+ await act(async () => {
+ waitForElementToBeRemoved(el);
+ });
+
+ act(function() {
+ const blah = screen.getByText('blah');
+ });
+
+ act(function() {
+ render(something);
+ });
+
+ await act(() => {
+ const button = findByRole('button')
+ });
+
+ act(() => {
+ userEvent.click(el)
+ });
+
+ act(() => {
+ waitFor();
+ const element = screen.getByText('blah');
+ userEvent.click(element)
+ });
+ });
+ `,
+ errors: [
+ { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 6, column: 9 },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 10,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 14,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 18,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 22,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 26,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 30,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 34,
+ column: 9,
+ },
+ ],
+ },
+ {
+ code: `// case: RTL act wrapping RTL calls - callbacks with return
+ import { act, fireEvent, screen, render, waitFor } from '@testing-library/react'
+ import userEvent from '@testing-library/user-event'
+
+ test('invalid case', async () => {
+ act(() => fireEvent.click(el))
+ act(() => screen.getByText('blah'))
+ act(() => findByRole('button'))
+ act(() => userEvent.click(el))
+ await act(async () => userEvent.type('hi', el))
+ act(() => render(foo))
+ await act(async () => render(fo))
+ act(() => waitFor(() => {}))
+ await act(async () => waitFor(() => {}))
+
+ act(function () {
+ return fireEvent.click(el);
+ });
+ act(function () {
+ return screen.getByText('blah');
+ });
+ act(function () {
+ return findByRole('button');
+ });
+ act(function () {
+ return userEvent.click(el);
+ });
+ await act(async function () {
+ return userEvent.type('hi', el);
+ });
+ act(function () {
+ return render(foo);
+ });
+ await act(async function () {
+ return render(fo);
+ });
+ act(function () {
+ return waitFor(() => {});
+ });
+ await act(async function () {
+ return waitFor(() => {});
+ });
+ act(async function () {
+ return waitFor(() => {});
+ }).then(() => {})
+ });
+ `,
+ errors: [
+ { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 6, column: 9 },
+ { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 7, column: 9 },
+ { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 8, column: 9 },
+ { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 9, column: 9 },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 10,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 11,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 12,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 13,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 14,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 16,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 19,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 22,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 25,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 28,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 31,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 34,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 37,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 40,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 43,
+ column: 9,
+ },
+ ],
+ },
+ {
+ code: `// case: RTL act wrapping empty callback
+ import { act } from '@testing-library/react'
+
+ test('invalid case', async () => {
+ await act(async () => {})
+ act(() => {})
+ await act(async function () {})
+ act(function () {})
+ })
+ `,
+ errors: [
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 5, column: 15 },
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 6, column: 9 },
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 7, column: 15 },
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 8, column: 9 },
+ ],
+ },
+ {
+ settings: {
+ 'testing-library/utils-module': 'test-utils',
+ },
+ code: `// case: RTL act wrapping empty callback - require version
+ const { act } = require('@testing-library/react');
+
+ test('invalid case', async () => {
+ await act(async () => {})
+ act(() => {})
+ await act(async function () {})
+ act(function () {})
+ act(function () {}).then(() => {})
+ })
+ `,
+ errors: [
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 5, column: 15 },
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 6, column: 9 },
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 7, column: 15 },
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 8, column: 9 },
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 9, column: 9 },
+ ],
+ },
+
+ // cases for act related to React Test Utils
+ {
+ settings: {
+ 'testing-library/utils-module': 'custom-testing-module',
+ },
+ code: `// case: RTU act wrapping RTL calls - callbacks with body (BlockStatement)
+ import { act } from 'react-dom/test-utils';
+ import { fireEvent, screen, render, waitFor, waitForElementToBeRemoved } from 'custom-testing-module'
+ import userEvent from '@testing-library/user-event'
+
+ test('invalid case', async () => {
+ act(() => {
+ fireEvent.click(el);
+ });
+
+ await act(async () => {
+ waitFor(() => {});
+ });
+
+ await act(async () => {
+ waitForElementToBeRemoved(el);
+ });
+
+ act(function() {
+ const blah = screen.getByText('blah');
+ });
+
+ act(function() {
+ render(something);
+ });
+
+ await act(() => {
+ const button = findByRole('button')
+ });
+
+ act(() => {
+ userEvent.click(el)
+ });
+
+ act(() => {
+ waitFor();
+ const element = screen.getByText('blah');
+ userEvent.click(element)
+ });
+ });
+ `,
+ errors: [
+ { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 7, column: 9 },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 11,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 15,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 19,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 23,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 27,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 31,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 35,
+ column: 9,
+ },
+ ],
+ },
+ {
+ settings: {
+ 'testing-library/utils-module': 'custom-testing-module',
+ },
+ code: `// case: RTU act wrapping RTL calls - callbacks with return
+ import { act } from 'react-dom/test-utils';
+ import { fireEvent, screen, render, waitFor } from 'custom-testing-module'
+ import userEvent from '@testing-library/user-event'
+
+ test('invalid case', async () => {
+ act(() => fireEvent.click(el))
+ act(() => screen.getByText('blah'))
+ act(() => findByRole('button'))
+ act(() => userEvent.click(el))
+ await act(async () => userEvent.type('hi', el))
+ act(() => render(foo))
+ await act(async () => render(fo))
+ act(() => waitFor(() => {}))
+ await act(async () => waitFor(() => {}))
+
+ act(function () {
+ return fireEvent.click(el);
+ });
+ act(function () {
+ return screen.getByText('blah');
+ });
+ act(function () {
+ return findByRole('button');
+ });
+ act(function () {
+ return userEvent.click(el);
+ });
+ await act(async function () {
+ return userEvent.type('hi', el);
+ });
+ act(function () {
+ return render(foo);
+ });
+ await act(async function () {
+ return render(fo);
+ });
+ act(function () {
+ return waitFor(() => {});
+ });
+ await act(async function () {
+ return waitFor(() => {});
+ });
+ });
+ `,
+ errors: [
+ { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 7, column: 9 },
+ { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 8, column: 9 },
+ { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 9, column: 9 },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 10,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 11,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 12,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 13,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 14,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 15,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 17,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 20,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 23,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 26,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 29,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 32,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 35,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 38,
+ column: 9,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 41,
+ column: 15,
+ },
+ ],
+ },
+ {
+ settings: {
+ 'testing-library/utils-module': 'off',
+ },
+ code: `// case: RTU act wrapping empty callback
+ import { act } from 'react-dom/test-utils';
+ import { render } from '@testing-library/react'
+
+ test('invalid case', async () => {
+ render(element);
+ await act(async () => {});
+ act(() => {});
+ await act(async function () {});
+ act(function () {});
+ });
+ `,
+ errors: [
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 7, column: 15 },
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 8, column: 9 },
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 9, column: 15 },
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 10, column: 9 },
+ ],
+ },
+ {
+ settings: {
+ 'testing-library/utils-module': 'off',
+ },
+ code: `// case: RTU act wrapping empty callback - require version
+ const { act } = require('react-dom/test-utils');
+ const { render } = require('@testing-library/react');
+
+ test('invalid case', async () => {
+ render(element);
+ await act(async () => {});
+ act(() => {});
+ await act(async function () {});
+ act(function () {});
+ })
+ `,
+ errors: [
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 7, column: 15 },
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 8, column: 9 },
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 9, column: 15 },
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 10, column: 9 },
+ ],
+ },
+
+ {
+ settings: {
+ 'testing-library/utils-module': 'custom-testing-module',
+ 'testing-library/custom-renders': 'off',
+ },
+ code: `// case: mixed scenarios - AGR disabled
+ import * as ReactTestUtils from 'react-dom/test-utils';
+ import { act as renamedAct, fireEvent, screen as renamedScreen, render, waitFor } from 'custom-testing-module'
+ import userEvent from '@testing-library/user-event'
+ import { act, waitForElementToBeRemoved } from 'somewhere-else'
+
+ test('invalid case', async () => {
+ ReactTestUtils.act(() => {})
+ await ReactTestUtils.act(() => render())
+ await renamedAct(async () => waitFor())
+ renamedAct(function() { renamedScreen.findByRole('button') })
+
+ // these are valid
+ await renamedAct(() => waitForElementToBeRemoved(element))
+ act(() => {})
+ await act(async () => { userEvent.click(element) })
+ act(function() { return renamedScreen.getByText('foo') })
+ });
+ `,
+ errors: [
+ { messageId: 'noUnnecessaryActEmptyFunction', line: 8, column: 24 },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 9,
+ column: 30,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 10,
+ column: 15,
+ },
+ {
+ messageId: 'noUnnecessaryActTestingLibraryUtil',
+ line: 11,
+ column: 9,
+ },
+ ],
+ },
+ ],
+});