From ba6923939d88d8e86ba577880c70ead9cadba159 Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Fri, 3 Oct 2025 23:06:44 +0900 Subject: [PATCH 1/8] feat: enhance assertion reporting logic --- lib/rules/no-wait-for-multiple-assertions.ts | 51 +++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/lib/rules/no-wait-for-multiple-assertions.ts b/lib/rules/no-wait-for-multiple-assertions.ts index 245bd9d1..5df28627 100644 --- a/lib/rules/no-wait-for-multiple-assertions.ts +++ b/lib/rules/no-wait-for-multiple-assertions.ts @@ -1,5 +1,10 @@ import { createTestingLibraryRule } from '../create-testing-library-rule'; -import { getPropertyIdentifierNode } from '../node-utils'; +import { + getPropertyIdentifierNode, + isCallExpression, + isMemberExpression, +} from '../node-utils'; +import { getSourceCode } from '../utils'; import type { TSESTree } from '@typescript-eslint/utils'; @@ -44,6 +49,24 @@ export default createTestingLibraryRule({ }) as Array; } + function getExpectArgument(expression: TSESTree.Expression) { + if (!isCallExpression(expression)) { + return null; + } + + const { callee } = expression; + if (!isMemberExpression(callee)) { + return null; + } + + const { object } = callee; + if (!isCallExpression(object) || object.arguments.length === 0) { + return null; + } + + return object.arguments[0]; + } + function reportMultipleAssertion(node: TSESTree.BlockStatement) { if (!node.parent) { return; @@ -62,14 +85,30 @@ export default createTestingLibraryRule({ const expectNodes = getExpectNodes(node.body); - if (expectNodes.length <= 1) { - return; + const expectArgumentMap = new Map< + string, + TSESTree.ExpressionStatement[] + >(); + + for (const expectNode of expectNodes) { + const argument = getExpectArgument(expectNode.expression); + if (!argument) { + continue; + } + + const argumentText = getSourceCode(context).getText(argument); + const existingNodes = expectArgumentMap.get(argumentText) ?? []; + const newTargetNodes = [...existingNodes, expectNode]; + expectArgumentMap.set(argumentText, newTargetNodes); } - for (let i = 0; i < expectNodes.length; i++) { - if (i !== 0) { + for (const expressionStatements of expectArgumentMap.values()) { + if (expressionStatements.length <= 1) { + continue; + } + for (const expressionStatement of expressionStatements.slice(1)) { context.report({ - node: expectNodes[i], + node: expressionStatement, messageId: 'noWaitForMultipleAssertion', }); } From a3eca9da508ef2051c811d673f825819f822a393 Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Fri, 3 Oct 2025 23:06:54 +0900 Subject: [PATCH 2/8] test: update tests --- .../no-wait-for-multiple-assertions.test.ts | 83 +++++++++++++++---- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/tests/lib/rules/no-wait-for-multiple-assertions.test.ts b/tests/lib/rules/no-wait-for-multiple-assertions.test.ts index 0bd4e610..b01a0f19 100644 --- a/tests/lib/rules/no-wait-for-multiple-assertions.test.ts +++ b/tests/lib/rules/no-wait-for-multiple-assertions.test.ts @@ -16,10 +16,10 @@ const ruleTester = createRuleTester(); const SUPPORTED_TESTING_FRAMEWORKS = [ '@testing-library/dom', - '@testing-library/angular', - '@testing-library/react', - '@testing-library/vue', - '@marko/testing-library', + // '@testing-library/angular', + // '@testing-library/react', + // '@testing-library/vue', + // '@marko/testing-library', ]; ruleTester.run(RULE_NAME, rule, { @@ -34,6 +34,22 @@ ruleTester.run(RULE_NAME, rule, { await waitFor(function() { expect(a).toEqual('a') }) + `, + }, + { + code: ` + await waitFor(function() { + expect(a).toEqual('a') + expect(b).toEqual('b') + }) + `, + }, + { + code: ` + await waitFor(function() { + expect(a).toEqual('a') + expect('a').toEqual('a') + }) `, }, { @@ -115,7 +131,18 @@ ruleTester.run(RULE_NAME, rule, { code: ` await waitFor(() => { expect(a).toEqual('a') - expect(b).toEqual('b') + expect(a).toEqual('a') + }) + `, + errors: [ + { line: 4, column: 11, messageId: 'noWaitForMultipleAssertion' }, + ], + }, + { + code: ` + await waitFor(() => { + expect(screen.getByTestId('a')).toHaveTextContent('a') + expect(screen.getByTestId('a')).toHaveTextContent('a') }) `, errors: [ @@ -129,7 +156,7 @@ ruleTester.run(RULE_NAME, rule, { import { waitFor } from '${testingFramework}' await waitFor(() => { expect(a).toEqual('a') - expect(b).toEqual('b') + expect(a).toEqual('a') }) `, errors: [ @@ -143,7 +170,7 @@ ruleTester.run(RULE_NAME, rule, { import { waitFor as renamedWaitFor } from 'test-utils' await renamedWaitFor(() => { expect(a).toEqual('a') - expect(b).toEqual('b') + expect(a).toEqual('a') }) `, errors: [ @@ -155,7 +182,7 @@ ruleTester.run(RULE_NAME, rule, { await waitFor(() => { expect(a).toEqual('a') console.log('testing-library') - expect(b).toEqual('b') + expect(a).toEqual('a') }) `, errors: [ @@ -168,7 +195,7 @@ ruleTester.run(RULE_NAME, rule, { await waitFor(() => { expect(a).toEqual('a') console.log('testing-library') - expect(b).toEqual('b') + expect(a).toEqual('a') }) }) `, @@ -181,7 +208,7 @@ ruleTester.run(RULE_NAME, rule, { await waitFor(async () => { expect(a).toEqual('a') await somethingAsync() - expect(b).toEqual('b') + expect(a).toEqual('a') }) `, errors: [ @@ -192,9 +219,9 @@ ruleTester.run(RULE_NAME, rule, { code: ` await waitFor(function() { expect(a).toEqual('a') - expect(b).toEqual('b') - expect(c).toEqual('c') - expect(d).toEqual('d') + expect(a).toEqual('a') + expect(a).toEqual('a') + expect(a).toEqual('a') }) `, errors: [ @@ -207,8 +234,22 @@ ruleTester.run(RULE_NAME, rule, { code: ` await waitFor(function() { expect(a).toEqual('a') - console.log('testing-library') + expect(a).toEqual('a') expect(b).toEqual('b') + expect(b).toEqual('b') + }) + `, + errors: [ + { line: 4, column: 11, messageId: 'noWaitForMultipleAssertion' }, + { line: 6, column: 11, messageId: 'noWaitForMultipleAssertion' }, + ], + }, + { + code: ` + await waitFor(function() { + expect(a).toEqual('a') + console.log('testing-library') + expect(a).toEqual('a') }) `, errors: [ @@ -220,8 +261,20 @@ ruleTester.run(RULE_NAME, rule, { await waitFor(async function() { expect(a).toEqual('a') const el = await somethingAsync() - expect(b).toEqual('b') + expect(a).toEqual('a') }) + `, + errors: [ + { line: 5, column: 11, messageId: 'noWaitForMultipleAssertion' }, + ], + }, + { + code: ` + await waitFor(() => { + expect(window.fetch).toHaveBeenCalledTimes(1); + expect(localStorage.setItem).toHaveBeenCalledWith('bar', 'baz'); + expect(window.fetch).toHaveBeenCalledWith('/foo'); + }); `, errors: [ { line: 5, column: 11, messageId: 'noWaitForMultipleAssertion' }, From 151d65cf0fa9df68a1ba5ebc3a5628faecc8fb44 Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Fri, 3 Oct 2025 23:15:40 +0900 Subject: [PATCH 3/8] docs: update doc --- docs/rules/no-wait-for-multiple-assertions.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/rules/no-wait-for-multiple-assertions.md b/docs/rules/no-wait-for-multiple-assertions.md index 83a3bd51..265fc6a4 100644 --- a/docs/rules/no-wait-for-multiple-assertions.md +++ b/docs/rules/no-wait-for-multiple-assertions.md @@ -7,9 +7,13 @@ ## Rule Details This rule aims to ensure the correct usage of `expect` inside `waitFor`, in the way that they're intended to be used. -When using multiple assertions inside `waitFor`, if one fails, you have to wait for a timeout before seeing it failing. -Putting one assertion, you can both wait for the UI to settle to the state you want to assert on, -and also fail faster if one of the assertions do end up failing + +If you use multiple assertions against the **same asynchronous target** inside `waitFor`, +you may have to wait for a timeout before seeing a test failure, which is inefficient. +Therefore, you should avoid using multiple assertions on the same async target inside a single `waitFor` callback. + +However, multiple assertions against **different async targets** (for example, independent state updates or different function calls) are allowed. +This avoids unnecessary verbosity and maintains readability, without increasing the risk of missing failures. Example of **incorrect** code for this rule: @@ -17,13 +21,13 @@ Example of **incorrect** code for this rule: const foo = async () => { await waitFor(() => { expect(a).toEqual('a'); - expect(b).toEqual('b'); + expect(a).toEqual('a'); }); // or await waitFor(function () { expect(a).toEqual('a'); - expect(b).toEqual('b'); + expect(a).toEqual('a'); }); }; ``` @@ -33,13 +37,13 @@ Examples of **correct** code for this rule: ```js const foo = async () => { await waitFor(() => expect(a).toEqual('a')); - expect(b).toEqual('b'); + expect(a).toEqual('a'); // or await waitFor(function () { expect(a).toEqual('a'); }); - expect(b).toEqual('b'); + expect(a).toEqual('a'); // it only detects expect // so this case doesn't generate warnings From b8230edff6f8aa9d460ff6473629f7d821537ee3 Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Fri, 3 Oct 2025 23:17:39 +0900 Subject: [PATCH 4/8] chore: uncomment code --- tests/lib/rules/no-wait-for-multiple-assertions.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/lib/rules/no-wait-for-multiple-assertions.test.ts b/tests/lib/rules/no-wait-for-multiple-assertions.test.ts index b01a0f19..f316acf2 100644 --- a/tests/lib/rules/no-wait-for-multiple-assertions.test.ts +++ b/tests/lib/rules/no-wait-for-multiple-assertions.test.ts @@ -16,10 +16,10 @@ const ruleTester = createRuleTester(); const SUPPORTED_TESTING_FRAMEWORKS = [ '@testing-library/dom', - // '@testing-library/angular', - // '@testing-library/react', - // '@testing-library/vue', - // '@marko/testing-library', + '@testing-library/angular', + '@testing-library/react', + '@testing-library/vue', + '@marko/testing-library', ]; ruleTester.run(RULE_NAME, rule, { From 908e84e5dfe0857e470160b4278e439030b887cb Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Mon, 6 Oct 2025 21:20:53 +0900 Subject: [PATCH 5/8] chore: add a comment explaining why slice starts from index 1 --- lib/rules/no-wait-for-multiple-assertions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/rules/no-wait-for-multiple-assertions.ts b/lib/rules/no-wait-for-multiple-assertions.ts index 5df28627..03769fd4 100644 --- a/lib/rules/no-wait-for-multiple-assertions.ts +++ b/lib/rules/no-wait-for-multiple-assertions.ts @@ -106,6 +106,7 @@ export default createTestingLibraryRule({ if (expressionStatements.length <= 1) { continue; } + // Skip the first matched assertion; only report subsequent duplicates. for (const expressionStatement of expressionStatements.slice(1)) { context.report({ node: expressionStatement, From ed3065130ad64f3970278a151f1b65609d46af6b Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Mon, 6 Oct 2025 21:21:49 +0900 Subject: [PATCH 6/8] refactor: remove unnecessary logic --- lib/rules/no-wait-for-multiple-assertions.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/rules/no-wait-for-multiple-assertions.ts b/lib/rules/no-wait-for-multiple-assertions.ts index 03769fd4..bf6a1fc3 100644 --- a/lib/rules/no-wait-for-multiple-assertions.ts +++ b/lib/rules/no-wait-for-multiple-assertions.ts @@ -103,9 +103,6 @@ export default createTestingLibraryRule({ } for (const expressionStatements of expectArgumentMap.values()) { - if (expressionStatements.length <= 1) { - continue; - } // Skip the first matched assertion; only report subsequent duplicates. for (const expressionStatement of expressionStatements.slice(1)) { context.report({ From 9a7199b64d5970ee3b8f5bdc6c38dca41a5da888 Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Mon, 6 Oct 2025 21:30:26 +0900 Subject: [PATCH 7/8] docs: update sample code --- docs/rules/no-wait-for-multiple-assertions.md | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/rules/no-wait-for-multiple-assertions.md b/docs/rules/no-wait-for-multiple-assertions.md index 265fc6a4..0ea96368 100644 --- a/docs/rules/no-wait-for-multiple-assertions.md +++ b/docs/rules/no-wait-for-multiple-assertions.md @@ -20,14 +20,14 @@ Example of **incorrect** code for this rule: ```js const foo = async () => { await waitFor(() => { - expect(a).toEqual('a'); - expect(a).toEqual('a'); + expect(window.fetch).toHaveBeenCalledWith('/foo'); + expect(window.fetch).toHaveBeenCalledWith('/foo'); }); // or await waitFor(function () { - expect(a).toEqual('a'); - expect(a).toEqual('a'); + expect(window.fetch).toHaveBeenCalledWith('/foo'); + expect(window.fetch).toHaveBeenCalledWith('/foo'); }); }; ``` @@ -36,20 +36,26 @@ Examples of **correct** code for this rule: ```js const foo = async () => { - await waitFor(() => expect(a).toEqual('a')); - expect(a).toEqual('a'); + await waitFor(() => expect(window.fetch).toHaveBeenCalledWith('foo')); + expect(window.fetch).toHaveBeenCalledTimes(1); // or await waitFor(function () { - expect(a).toEqual('a'); + expect(window.fetch).toHaveBeenCalledWith('foo'); }); - expect(a).toEqual('a'); + expect(window.fetch).toHaveBeenCalledTimes(1); // it only detects expect // so this case doesn't generate warnings await waitFor(() => { fireEvent.keyDown(input, { key: 'ArrowDown' }); - expect(b).toEqual('b'); + expect(window.fetch).toHaveBeenCalledTimes(1); + }); + + // different async targets so the rule does not report it + await waitFor(() => { + expect(window.fetch).toHaveBeenCalledWith('/foo'); + expect(localStorage.setItem).toHaveBeenCalledWith('bar', 'baz'); }); }; ``` From 1d77160122ff232882a7380167f1620dac1008dc Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Mon, 6 Oct 2025 22:32:50 +0900 Subject: [PATCH 8/8] docs: update doc --- docs/rules/no-wait-for-multiple-assertions.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/rules/no-wait-for-multiple-assertions.md b/docs/rules/no-wait-for-multiple-assertions.md index 0ea96368..fae909ac 100644 --- a/docs/rules/no-wait-for-multiple-assertions.md +++ b/docs/rules/no-wait-for-multiple-assertions.md @@ -20,13 +20,13 @@ Example of **incorrect** code for this rule: ```js const foo = async () => { await waitFor(() => { - expect(window.fetch).toHaveBeenCalledWith('/foo'); + expect(window.fetch).toHaveBeenCalledTimes(1); expect(window.fetch).toHaveBeenCalledWith('/foo'); }); // or await waitFor(function () { - expect(window.fetch).toHaveBeenCalledWith('/foo'); + expect(window.fetch).toHaveBeenCalledTimes(1); expect(window.fetch).toHaveBeenCalledWith('/foo'); }); }; @@ -36,14 +36,14 @@ Examples of **correct** code for this rule: ```js const foo = async () => { - await waitFor(() => expect(window.fetch).toHaveBeenCalledWith('foo')); - expect(window.fetch).toHaveBeenCalledTimes(1); + await waitFor(() => expect(window.fetch).toHaveBeenCalledTimes(1); + expect(window.fetch).toHaveBeenCalledWith('/foo'); // or await waitFor(function () { - expect(window.fetch).toHaveBeenCalledWith('foo'); + expect(window.fetch).toHaveBeenCalledTimes(1); }); - expect(window.fetch).toHaveBeenCalledTimes(1); + expect(window.fetch).toHaveBeenCalledWith('/foo'); // it only detects expect // so this case doesn't generate warnings