diff --git a/docs/rules/no-wait-for-side-effects.md b/docs/rules/no-wait-for-side-effects.md index 6f81179d..b545fae5 100644 --- a/docs/rules/no-wait-for-side-effects.md +++ b/docs/rules/no-wait-for-side-effects.md @@ -2,7 +2,7 @@ ## Rule Details -This rule aims to avoid the usage of side effects actions (`fireEvent` or `userEvent`) inside `waitFor`. +This rule aims to avoid the usage of side effects actions (`fireEvent`, `userEvent` or `render`) inside `waitFor`. Since `waitFor` is intended for things that have a non-deterministic amount of time between the action you performed and the assertion passing, the callback can be called (or checked for errors) a non-deterministic number of times and frequency. This will make your side-effect run multiple times. @@ -32,6 +32,18 @@ Example of **incorrect** code for this rule: userEvent.click(button); expect(b).toEqual('b'); }); + + // or + await waitFor(() => { + render() + expect(b).toEqual('b'); + }); + + // or + await waitFor(function() { + render() + expect(b).toEqual('b'); + }); }; ``` @@ -60,6 +72,18 @@ Examples of **correct** code for this rule: await waitFor(function() { expect(b).toEqual('b'); }); + + // or + render() + await waitFor(() => { + expect(b).toEqual('b'); + }); + + // or + render() + await waitFor(function() { + expect(b).toEqual('b'); + }); }; ``` diff --git a/lib/node-utils/is-node-of-type.ts b/lib/node-utils/is-node-of-type.ts index 89d8880b..ac8dbb5f 100644 --- a/lib/node-utils/is-node-of-type.ts +++ b/lib/node-utils/is-node-of-type.ts @@ -16,6 +16,15 @@ export const isCallExpression = isNodeOfType(AST_NODE_TYPES.CallExpression); export const isExpressionStatement = isNodeOfType( AST_NODE_TYPES.ExpressionStatement ); +export const isVariableDeclaration = isNodeOfType( + AST_NODE_TYPES.VariableDeclaration +); +export const isAssignmentExpression = isNodeOfType( + AST_NODE_TYPES.AssignmentExpression +); +export const isSequenceExpression = isNodeOfType( + AST_NODE_TYPES.SequenceExpression +); export const isImportDeclaration = isNodeOfType( AST_NODE_TYPES.ImportDeclaration ); diff --git a/lib/rules/no-wait-for-side-effects.ts b/lib/rules/no-wait-for-side-effects.ts index c48735ed..b9a9cdd5 100644 --- a/lib/rules/no-wait-for-side-effects.ts +++ b/lib/rules/no-wait-for-side-effects.ts @@ -2,6 +2,10 @@ import { TSESTree } from '@typescript-eslint/experimental-utils'; import { getPropertyIdentifierNode, isExpressionStatement, + isVariableDeclaration, + isAssignmentExpression, + isCallExpression, + isSequenceExpression, } from '../node-utils'; import { createTestingLibraryRule } from '../create-testing-library-rule'; @@ -32,7 +36,11 @@ export default createTestingLibraryRule({ defaultOptions: [], create: function (context, _, helpers) { function isCallerWaitFor( - node: TSESTree.BlockStatement | TSESTree.CallExpression + node: + | TSESTree.BlockStatement + | TSESTree.CallExpression + | TSESTree.AssignmentExpression + | TSESTree.SequenceExpression ): boolean { if (!node.parent) { return false; @@ -48,22 +56,78 @@ export default createTestingLibraryRule({ ); } + function isRenderInVariableDeclaration(node: TSESTree.Node) { + return ( + isVariableDeclaration(node) && + node.declarations.some(helpers.isRenderVariableDeclarator) + ); + } + + function isRenderInExpressionStatement(node: TSESTree.Node) { + if ( + !isExpressionStatement(node) || + !isAssignmentExpression(node.expression) + ) { + return false; + } + + const expressionIdentifier = getPropertyIdentifierNode( + node.expression.right + ); + + if (!expressionIdentifier) { + return false; + } + + return helpers.isRenderUtil(expressionIdentifier); + } + + function isRenderInAssignmentExpression(node: TSESTree.Node) { + if (!isAssignmentExpression(node)) { + return false; + } + + const expressionIdentifier = getPropertyIdentifierNode(node.right); + if (!expressionIdentifier) { + return false; + } + + return helpers.isRenderUtil(expressionIdentifier); + } + + function isRenderInSequenceAssignment(node: TSESTree.Node) { + if (!isSequenceExpression(node)) { + return false; + } + + return node.expressions.some(isRenderInAssignmentExpression); + } + function getSideEffectNodes( body: TSESTree.Node[] ): TSESTree.ExpressionStatement[] { return body.filter((node) => { - if (!isExpressionStatement(node)) { + if (!isExpressionStatement(node) && !isVariableDeclaration(node)) { return false; } + if ( + isRenderInVariableDeclaration(node) || + isRenderInExpressionStatement(node) + ) { + return true; + } + const expressionIdentifier = getPropertyIdentifierNode(node); + if (!expressionIdentifier) { return false; } return ( helpers.isFireEventUtil(expressionIdentifier) || - helpers.isUserEventUtil(expressionIdentifier) + helpers.isUserEventUtil(expressionIdentifier) || + helpers.isRenderUtil(expressionIdentifier) ); }) as TSESTree.ExpressionStatement[]; } @@ -86,19 +150,33 @@ export default createTestingLibraryRule({ } } - function reportImplicitReturnSideEffect(node: TSESTree.CallExpression) { + function reportImplicitReturnSideEffect( + node: + | TSESTree.CallExpression + | TSESTree.AssignmentExpression + | TSESTree.SequenceExpression + ) { if (!isCallerWaitFor(node)) { return; } - const expressionIdentifier = getPropertyIdentifierNode(node.callee); - if (!expressionIdentifier) { + const expressionIdentifier = isCallExpression(node) + ? getPropertyIdentifierNode(node.callee) + : null; + + if ( + !expressionIdentifier && + !isRenderInAssignmentExpression(node) && + !isRenderInSequenceAssignment(node) + ) { return; } if ( + expressionIdentifier && !helpers.isFireEventUtil(expressionIdentifier) && - !helpers.isUserEventUtil(expressionIdentifier) + !helpers.isUserEventUtil(expressionIdentifier) && + !helpers.isRenderUtil(expressionIdentifier) ) { return; } @@ -112,6 +190,8 @@ export default createTestingLibraryRule({ return { 'CallExpression > ArrowFunctionExpression > BlockStatement': reportSideEffects, 'CallExpression > ArrowFunctionExpression > CallExpression': reportImplicitReturnSideEffect, + 'CallExpression > ArrowFunctionExpression > AssignmentExpression': reportImplicitReturnSideEffect, + 'CallExpression > ArrowFunctionExpression > SequenceExpression': reportImplicitReturnSideEffect, 'CallExpression > FunctionExpression > BlockStatement': reportSideEffects, }; }, diff --git a/tests/lib/rules/no-wait-for-side-effects.test.ts b/tests/lib/rules/no-wait-for-side-effects.test.ts index 8d1b552d..f802ffe5 100644 --- a/tests/lib/rules/no-wait-for-side-effects.test.ts +++ b/tests/lib/rules/no-wait-for-side-effects.test.ts @@ -180,8 +180,340 @@ ruleTester.run(RULE_NAME, rule, { await waitFor(() => userEvent.click(button)) `, }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { waitFor } from 'somewhere-else'; + await waitFor(() => render()) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { waitFor } from 'somewhere-else'; + await waitFor(() => { + const { container } = render() + }) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { waitFor } from 'somewhere-else'; + const { rerender } = render() + await waitFor(() => { + rerender() + }) + `, + }, + { + settings: { 'testing-library/utils-module': '~/test-utils' }, + code: ` + import { waitFor } from '~/test-utils'; + import { render } from 'somewhere-else'; + await waitFor(() => render()) + `, + }, + { + settings: { 'testing-library/utils-module': '~/test-utils' }, + code: ` + import { waitFor } from '@testing-library/react'; + import { render } from 'somewhere-else'; + await waitFor(() => render()) + `, + }, + { + settings: { 'testing-library/custom-renders': ['renderHelper'] }, + code: ` + import { waitFor } from '@testing-library/react'; + import { renderWrapper } from 'somewhere-else'; + await waitFor(() => renderWrapper()) + `, + }, + { + settings: { 'testing-library/custom-renders': ['renderHelper'] }, + code: ` + import { waitFor } from '@testing-library/react'; + import { renderWrapper } from 'somewhere-else'; + await waitFor(() => { + renderWrapper() + }) + `, + }, + { + settings: { 'testing-library/custom-renders': ['renderHelper'] }, + code: ` + import { waitFor } from '@testing-library/react'; + import { renderWrapper } from 'somewhere-else'; + await waitFor(() => { + const { container } = renderWrapper() + }) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { waitFor } from 'somewhere-else'; + await waitFor(() => { + render() + }) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { waitFor } from 'test-utils'; + import { render } from 'somewhere-else'; + await waitFor(() => { + render() + }) + `, + }, + { + settings: { 'testing-library/custom-renders': ['renderHelper'] }, + code: ` + import { waitFor } from '@testing-library/react'; + import { renderWrapper } from 'somewhere-else'; + await waitFor(() => { + renderWrapper() + }) + `, + }, + { + settings: { 'testing-library/custom-renders': ['renderHelper'] }, + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => result = renderWrapper()) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { waitFor } from 'test-utils'; + import { render } from 'somewhere-else'; + await waitFor(() => result = render()) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { waitFor } from 'somewhere-else'; + await waitFor(() => result = render()) + `, + }, ], invalid: [ + // render + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => render()) + `, + errors: [{ line: 3, column: 29, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(function() { + render() + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(function() { + const { container } = renderHelper() + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + settings: { 'testing-library/custom-renders': ['renderHelper'] }, + code: ` + import { waitFor } from '@testing-library/react'; + import { renderHelper } from 'somewhere-else'; + await waitFor(() => renderHelper()) + `, + errors: [{ line: 4, column: 29, messageId: 'noSideEffectsWaitFor' }], + }, + { + settings: { 'testing-library/custom-renders': ['renderHelper'] }, + code: ` + import { waitFor } from '@testing-library/react'; + import { renderHelper } from 'somewhere-else'; + await waitFor(() => { + renderHelper() + }) + `, + errors: [{ line: 5, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + settings: { 'testing-library/custom-renders': ['renderHelper'] }, + code: ` + import { waitFor } from '@testing-library/react'; + import { renderHelper } from 'somewhere-else'; + await waitFor(() => { + const { container } = renderHelper() + }) + `, + errors: [{ line: 5, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + settings: { 'testing-library/custom-renders': ['renderHelper'] }, + code: ` + import { waitFor } from '@testing-library/react'; + import { renderHelper } from 'somewhere-else'; + let container; + await waitFor(() => { + ({ container } = renderHelper()) + }) + `, + errors: [{ line: 6, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => result = render()) + `, + errors: [{ line: 3, column: 29, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => (a = 5, result = render())) + `, + errors: [{ line: 3, column: 30, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + const { rerender } = render() + await waitFor(() => rerender()) + `, + errors: [{ line: 4, column: 29, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor, render } from '@testing-library/react'; + await waitFor(() => render()) + `, + errors: [{ line: 3, column: 29, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + const { rerender } = render() + await waitFor(() => rerender()) + `, + errors: [{ line: 4, column: 29, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => renderHelper()) + `, + errors: [{ line: 3, column: 29, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + import { render } from 'somewhere-else'; + await waitFor(() => render()) + `, + errors: [{ line: 4, column: 29, messageId: 'noSideEffectsWaitFor' }], + }, + { + settings: { 'testing-library/utils-module': '~/test-utils' }, + code: ` + import { waitFor, render } from '~/test-utils'; + await waitFor(() => render()) + `, + errors: [{ line: 3, column: 29, messageId: 'noSideEffectsWaitFor' }], + }, + { + settings: { 'testing-library/custom-renders': ['renderWrapper'] }, + code: ` + import { waitFor } from '@testing-library/react'; + import { renderWrapper } from 'somewhere-else'; + await waitFor(() => renderWrapper()) + `, + errors: [{ line: 4, column: 29, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + render() + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + const { container } = render() + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + result = render() + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + const a = 5, + { container } = render() + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + const { rerender } = render() + await waitFor(() => { + rerender() + }) + `, + errors: [{ line: 5, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + render() + fireEvent.keyDown(input, {key: 'ArrowDown'}) + }) + `, + errors: [ + { line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }, + { line: 5, column: 11, messageId: 'noSideEffectsWaitFor' }, + ], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + render() + userEvent.click(button) + }) + `, + errors: [ + { line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }, + { line: 5, column: 11, messageId: 'noSideEffectsWaitFor' }, + ], + }, // fireEvent { code: `