From c5ddd2cd983895a678b6c301fd6d637bea1348e7 Mon Sep 17 00:00:00 2001 From: Josh Miltier Date: Mon, 30 Mar 2026 18:37:34 -0600 Subject: [PATCH 1/6] feat: introduce new rule to allow eslint to pick up on potential ghost testing --- README.md | 1 + docs/rules/no-unsettled-absence-query.md | 86 ++++ src/rules/index.ts | 2 + src/rules/no-unsettled-absence-query.ts | 251 ++++++++++++ .../rules/no-unsettled-absence-query.test.ts | 377 ++++++++++++++++++ 5 files changed, 717 insertions(+) create mode 100644 docs/rules/no-unsettled-absence-query.md create mode 100644 src/rules/no-unsettled-absence-query.ts create mode 100644 tests/rules/no-unsettled-absence-query.test.ts diff --git a/README.md b/README.md index 6e334723..9985eae9 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,7 @@ module.exports = [ | [no-render-in-lifecycle](docs/rules/no-render-in-lifecycle.md) | Disallow the use of `render` in testing frameworks setup functions | ![badge-angular](https://img.shields.io/badge/-Angular-black?style=flat-square&logo=angular&logoColor=white&labelColor=DD0031&color=black) ![badge-marko](https://img.shields.io/badge/-Marko-black?style=flat-square&logo=marko&logoColor=white&labelColor=2596BE&color=black) ![badge-react](https://img.shields.io/badge/-React-black?style=flat-square&logo=react&logoColor=white&labelColor=61DAFB&color=black) ![badge-svelte](https://img.shields.io/badge/-Svelte-black?style=flat-square&logo=svelte&logoColor=white&labelColor=FF3E00&color=black) ![badge-vue](https://img.shields.io/badge/-Vue-black?style=flat-square&logo=vue.js&logoColor=white&labelColor=4FC08D&color=black) | | | | [no-test-id-queries](docs/rules/no-test-id-queries.md) | Ensure no `data-testid` queries are used | | | | | [no-unnecessary-act](docs/rules/no-unnecessary-act.md) | Disallow wrapping Testing Library utils or empty callbacks in `act` | ![badge-marko](https://img.shields.io/badge/-Marko-black?style=flat-square&logo=marko&logoColor=white&labelColor=2596BE&color=black) ![badge-react](https://img.shields.io/badge/-React-black?style=flat-square&logo=react&logoColor=white&labelColor=61DAFB&color=black) | | | +| [no-unsettled-absence-query](docs/rules/no-unsettled-absence-query.md) | Disallow absence assertions on `queryBy*` before the component has settled | | | | | [no-wait-for-multiple-assertions](docs/rules/no-wait-for-multiple-assertions.md) | Disallow the use of multiple `expect` calls inside `waitFor` | ![badge-angular](https://img.shields.io/badge/-Angular-black?style=flat-square&logo=angular&logoColor=white&labelColor=DD0031&color=black) ![badge-dom](https://img.shields.io/badge/%F0%9F%90%99-DOM-black?style=flat-square) ![badge-marko](https://img.shields.io/badge/-Marko-black?style=flat-square&logo=marko&logoColor=white&labelColor=2596BE&color=black) ![badge-react](https://img.shields.io/badge/-React-black?style=flat-square&logo=react&logoColor=white&labelColor=61DAFB&color=black) ![badge-svelte](https://img.shields.io/badge/-Svelte-black?style=flat-square&logo=svelte&logoColor=white&labelColor=FF3E00&color=black) ![badge-vue](https://img.shields.io/badge/-Vue-black?style=flat-square&logo=vue.js&logoColor=white&labelColor=4FC08D&color=black) | | 🔧 | | [no-wait-for-side-effects](docs/rules/no-wait-for-side-effects.md) | Disallow the use of side effects in `waitFor` | ![badge-angular](https://img.shields.io/badge/-Angular-black?style=flat-square&logo=angular&logoColor=white&labelColor=DD0031&color=black) ![badge-dom](https://img.shields.io/badge/%F0%9F%90%99-DOM-black?style=flat-square) ![badge-marko](https://img.shields.io/badge/-Marko-black?style=flat-square&logo=marko&logoColor=white&labelColor=2596BE&color=black) ![badge-react](https://img.shields.io/badge/-React-black?style=flat-square&logo=react&logoColor=white&labelColor=61DAFB&color=black) ![badge-svelte](https://img.shields.io/badge/-Svelte-black?style=flat-square&logo=svelte&logoColor=white&labelColor=FF3E00&color=black) ![badge-vue](https://img.shields.io/badge/-Vue-black?style=flat-square&logo=vue.js&logoColor=white&labelColor=4FC08D&color=black) | | 🔧 | | [no-wait-for-snapshot](docs/rules/no-wait-for-snapshot.md) | Ensures no snapshot is generated inside of a `waitFor` call | ![badge-angular](https://img.shields.io/badge/-Angular-black?style=flat-square&logo=angular&logoColor=white&labelColor=DD0031&color=black) ![badge-dom](https://img.shields.io/badge/%F0%9F%90%99-DOM-black?style=flat-square) ![badge-marko](https://img.shields.io/badge/-Marko-black?style=flat-square&logo=marko&logoColor=white&labelColor=2596BE&color=black) ![badge-react](https://img.shields.io/badge/-React-black?style=flat-square&logo=react&logoColor=white&labelColor=61DAFB&color=black) ![badge-svelte](https://img.shields.io/badge/-Svelte-black?style=flat-square&logo=svelte&logoColor=white&labelColor=FF3E00&color=black) ![badge-vue](https://img.shields.io/badge/-Vue-black?style=flat-square&logo=vue.js&logoColor=white&labelColor=4FC08D&color=black) | | | diff --git a/docs/rules/no-unsettled-absence-query.md b/docs/rules/no-unsettled-absence-query.md new file mode 100644 index 00000000..215c23e3 --- /dev/null +++ b/docs/rules/no-unsettled-absence-query.md @@ -0,0 +1,86 @@ +# testing-library/no-unsettled-absence-query + +📝 Disallow absence assertions on `queryBy*` before the component has settled. + + + +Asserting absence with `queryBy*` + `.not.toBeInTheDocument()` (or `.not.toBeVisible()`) immediately after `render()`, before the component has settled, can produce a false positive. The element isn't there _yet_, not because it _won't_ be there. This is commonly referred to as **Testing Ghosts**. + +## Rule details + +This rule fires when an absence assertion on a `queryBy*` / `queryAllBy*` query appears before any **settling expression** in the test body. + +**What counts as "settled":** + +1. Any `await` expression on a preceding statement — covers `findBy*`, `waitFor`, `act`, or custom async helpers. +2. A `getBy*` / `getAllBy*` call on a preceding statement — proves the synchronous render produced expected output. + +**Additionally**, absence assertions inside a `waitFor` callback are always flagged, because `waitFor` retries until the assertion passes — an absence check passes on the first invocation before the component has settled. + +Examples of **incorrect** code for this rule: + +```js +// Absence assertion before component has settled +test('shows no error', () => { + render(); + expect(screen.queryByText('error')).not.toBeInTheDocument(); +}); + +// Absence assertion BEFORE the await — order matters +test('shows no error', async () => { + render(); + expect(screen.queryByText('error')).not.toBeInTheDocument(); + await screen.findByText('loaded'); +}); + +// queryAllBy variant +test('shows no alerts', () => { + render(); + expect(screen.queryAllByRole('alert')).not.toBeInTheDocument(); +}); + +// Absence assertion inside waitFor — passes on first retry, still a ghost +test('shows no error', async () => { + render(); + await waitFor(() => { + expect(screen.queryByText('error')).not.toBeInTheDocument(); + }); +}); +``` + +Examples of **correct** code for this rule: + +```js +// findBy* settles the component first +test('shows no error', async () => { + render(); + await screen.findByText('loaded'); + expect(screen.queryByText('error')).not.toBeInTheDocument(); +}); + +// waitFor settles the component first +test('shows no error', async () => { + render(); + await waitFor(() => expect(something).toBe(true)); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); + +// act settles the component first +test('shows no error', async () => { + await act(() => render()); + expect(screen.queryByText('error')).not.toBeInTheDocument(); +}); + +// getBy* proves sync render completed +test('shows no error', () => { + render(); + screen.getByText('visible heading'); + expect(screen.queryByText('error')).not.toBeInTheDocument(); +}); +``` + +## Further Reading + +- [Testing Library: Appearance and Disappearance guide](https://testing-library.com/docs/guide-disappearance/) +- [Kent C. Dodds: Common mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library) +- [Gerardo Perrucci: Stop Testing Ghosts](https://www.gperrucci.com/blog/react/assert-non-existence-react-testing-library) diff --git a/src/rules/index.ts b/src/rules/index.ts index 06a671c4..c17c4046 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -14,6 +14,7 @@ import noPromiseInFireEvent from './no-promise-in-fire-event'; import noRenderInLifecycle from './no-render-in-lifecycle'; import noTestIdQueries from './no-test-id-queries'; import noUnnecessaryAct from './no-unnecessary-act'; +import noUnsettledAbsenceQuery from './no-unsettled-absence-query'; import noWaitForMultipleAssertions from './no-wait-for-multiple-assertions'; import noWaitForSideEffects from './no-wait-for-side-effects'; import noWaitForSnapshot from './no-wait-for-snapshot'; @@ -45,6 +46,7 @@ export const rules = { 'no-render-in-lifecycle': noRenderInLifecycle, 'no-test-id-queries': noTestIdQueries, 'no-unnecessary-act': noUnnecessaryAct, + 'no-unsettled-absence-query': noUnsettledAbsenceQuery, 'no-wait-for-multiple-assertions': noWaitForMultipleAssertions, 'no-wait-for-side-effects': noWaitForSideEffects, 'no-wait-for-snapshot': noWaitForSnapshot, diff --git a/src/rules/no-unsettled-absence-query.ts b/src/rules/no-unsettled-absence-query.ts new file mode 100644 index 00000000..0b24f844 --- /dev/null +++ b/src/rules/no-unsettled-absence-query.ts @@ -0,0 +1,251 @@ +import { ASTUtils } from '@typescript-eslint/utils'; + +import { createTestingLibraryRule } from '../create-testing-library-rule'; +import { + findClosestCallNode, + getAssertNodeInfo, + getDeepestIdentifierNode, + isArrowFunctionExpression, + isBlockStatement, + isCallExpression, + isFunctionExpression, + isMemberExpression, +} from '../node-utils'; + +import type { TSESTree } from '@typescript-eslint/utils'; + +const RULE_NAME = 'no-unsettled-absence-query'; +export type MessageIds = 'noUnsettledAbsenceQuery'; +export type Options = []; + +// Matchers that indicate absence when negated, beyond those already +// covered by helpers.isAbsenceAssert() (which handles PRESENCE_MATCHERS). +const NEGATED_ABSENCE_MATCHERS = ['toBeVisible']; + +export default createTestingLibraryRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: + 'Disallow absence assertions on `queryBy*` before the component has settled', + recommendedConfig: { + dom: false, + angular: false, + react: false, + vue: false, + svelte: false, + marko: false, + }, + }, + messages: { + noUnsettledAbsenceQuery: + 'Absence assertion on `{{queryMethod}}` appears before the component has settled. ' + + 'The element may not have rendered yet, resulting in a false positive. ' + + 'Add an `await` expression (e.g. `findBy*`, `waitFor`, `act`) or a `getBy*` call before this assertion.', + }, + schema: [], + }, + defaultOptions: [], + + create(context, _, helpers) { + function isAbsenceAssertion(node: TSESTree.MemberExpression): boolean { + if (helpers.isAbsenceAssert(node)) { + return true; + } + + const { matcher, isNegated } = getAssertNodeInfo(node); + return ( + isNegated !== false && + matcher !== null && + NEGATED_ABSENCE_MATCHERS.includes(matcher) + ); + } + + /** + * Determines whether a node is inside a callback passed to an async + * Testing Library utility (e.g. waitFor). Absence assertions inside + * these callbacks are always flagged because they can pass on the first + * invocation before the component has settled. + */ + function isInsideAsyncUtilCallback(node: TSESTree.Node): boolean { + let current: TSESTree.Node | undefined = node.parent; + + while (current) { + if ( + (isArrowFunctionExpression(current) || + isFunctionExpression(current)) && + isCallExpression(current.parent) + ) { + const calleeIdentifier = getDeepestIdentifierNode( + current.parent.callee + ); + if (calleeIdentifier && helpers.isAsyncUtil(calleeIdentifier)) { + return true; + } + } + current = current.parent; + } + return false; + } + + function findEnclosingFunctionBody( + node: TSESTree.Node + ): TSESTree.Statement[] | null { + let current: TSESTree.Node | undefined = node.parent; + + while (current) { + if ( + (isArrowFunctionExpression(current) || + isFunctionExpression(current)) && + isBlockStatement(current.body) + ) { + return current.body.body; + } + current = current.parent; + } + return null; + } + + function findAncestorStatement( + node: TSESTree.Node, + statements: TSESTree.Statement[] + ): TSESTree.Statement | null { + let current: TSESTree.Node = node; + while (current.parent) { + if (statements.includes(current as TSESTree.Statement)) { + return current as TSESTree.Statement; + } + current = current.parent; + } + return null; + } + + function containsAwaitExpression(node: TSESTree.Node): boolean { + if (ASTUtils.isAwaitExpression(node)) { + return true; + } + + for (const key of Object.keys(node)) { + if (key === 'parent') continue; + const child = (node as unknown as Record)[key]; + if (child && typeof child === 'object') { + if (Array.isArray(child)) { + for (const item of child) { + if ( + item && + typeof item === 'object' && + 'type' in item && + containsAwaitExpression(item as TSESTree.Node) + ) { + return true; + } + } + } else if ( + 'type' in child && + containsAwaitExpression(child as TSESTree.Node) + ) { + return true; + } + } + } + return false; + } + + function containsGetQueryCall(node: TSESTree.Node): boolean { + if (ASTUtils.isIdentifier(node) && helpers.isGetQueryVariant(node)) { + return true; + } + + for (const key of Object.keys(node)) { + if (key === 'parent') continue; + const child = (node as unknown as Record)[key]; + if (child && typeof child === 'object') { + if (Array.isArray(child)) { + for (const item of child) { + if ( + item && + typeof item === 'object' && + 'type' in item && + containsGetQueryCall(item as TSESTree.Node) + ) { + return true; + } + } + } else if ( + 'type' in child && + containsGetQueryCall(child as TSESTree.Node) + ) { + return true; + } + } + } + return false; + } + + function hasSettlingExpression(statement: TSESTree.Statement): boolean { + return ( + containsAwaitExpression(statement) || containsGetQueryCall(statement) + ); + } + + return { + 'CallExpression Identifier'(node: TSESTree.Identifier) { + // Only interested in queryBy* / queryAllBy* variants + if (!helpers.isQueryQueryVariant(node)) { + return; + } + + // Must be inside an expect() call + const expectCallNode = findClosestCallNode(node, 'expect'); + if ( + !expectCallNode?.parent || + !isMemberExpression(expectCallNode.parent) + ) { + return; + } + + // Must be an absence assertion + if (!isAbsenceAssertion(expectCallNode.parent)) { + return; + } + + // Absence assertions inside async util callbacks (e.g. waitFor) are + // always flagged — they pass on the first invocation before the + // component has settled. + if (isInsideAsyncUtilCallback(node)) { + context.report({ + node, + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: node.name }, + }); + return; + } + + // Find the enclosing function body and determine whether a settling + // expression appears on any preceding statement. + const functionBody = findEnclosingFunctionBody(node); + if (!functionBody) { + return; + } + + const containingStatement = findAncestorStatement(node, functionBody); + if (!containingStatement) { + return; + } + + const stmtIndex = functionBody.indexOf(containingStatement); + const precedingStatements = functionBody.slice(0, stmtIndex); + const hasSettled = precedingStatements.some(hasSettlingExpression); + + if (!hasSettled) { + context.report({ + node, + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: node.name }, + }); + } + }, + }; + }, +}); diff --git a/tests/rules/no-unsettled-absence-query.test.ts b/tests/rules/no-unsettled-absence-query.test.ts new file mode 100644 index 00000000..2ae7ac51 --- /dev/null +++ b/tests/rules/no-unsettled-absence-query.test.ts @@ -0,0 +1,377 @@ +import rule from '../../src/rules/no-unsettled-absence-query'; +import { createRuleTester } from '../test-utils'; + +import type { + MessageIds, + Options, +} from '../../src/rules/no-unsettled-absence-query'; +import type { + InvalidTestCase, + ValidTestCase, +} from '@typescript-eslint/rule-tester'; + +const ruleTester = createRuleTester(); + +type RuleValidTestCase = ValidTestCase; +type RuleInvalidTestCase = InvalidTestCase; + +const SUPPORTED_TESTING_FRAMEWORKS = [ + ['@testing-library/react', 'React TL'], + ['@testing-library/vue', 'Vue TL'], + ['@testing-library/angular', 'Angular TL'], +] as const; + +ruleTester.run('no-unsettled-absence-query', rule, { + valid: [ + // -- Settled by findBy* -- + ...SUPPORTED_TESTING_FRAMEWORKS.map( + ([testingFramework, label]) => ({ + code: `// case: ${label} — settled by findBy + import { render, screen } from '${testingFramework}' + + test('shows no error', async () => { + render() + await screen.findByText('loaded') + expect(screen.queryByText('error')).not.toBeInTheDocument() + }) + `, + }) + ), + + // -- Settled by waitFor -- + { + code: `// case: settled by waitFor + import { render, screen, waitFor } from '@testing-library/react' + + test('shows no error', async () => { + render() + await waitFor(() => expect(something).toBe(true)) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + `, + }, + + // -- Settled by act -- + { + code: `// case: settled by act wrapping render + import { render, screen, act } from '@testing-library/react' + + test('shows no error', async () => { + await act(() => render()) + expect(screen.queryByText('error')).not.toBeInTheDocument() + }) + `, + }, + + // -- Settled by getBy* -- + { + code: `// case: settled by getByText + import { render, screen } from '@testing-library/react' + + test('shows no error', () => { + render() + screen.getByText('visible heading') + expect(screen.queryByText('error')).not.toBeInTheDocument() + }) + `, + }, + + // -- Settled by getBy* inside expect -- + { + code: `// case: settled by getByRole inside expect assertion + import { render, screen } from '@testing-library/react' + + test('shows no error', () => { + render() + expect(screen.getByRole('heading')).toBeVisible() + expect(screen.queryByText('error')).not.toBeInTheDocument() + }) + `, + }, + + // -- Settled by getAllBy* -- + { + code: `// case: settled by getAllByText + import { render, screen } from '@testing-library/react' + + test('shows no alerts', () => { + render() + screen.getAllByText('item') + expect(screen.queryByRole('alert')).not.toBeVisible() + }) + `, + }, + + // -- .not.toBeVisible after settling -- + { + code: `// case: .not.toBeVisible after findBy + import { render, screen } from '@testing-library/react' + + test('dialog is hidden', async () => { + render() + await screen.findByText('loaded') + expect(screen.queryByRole('dialog')).not.toBeVisible() + }) + `, + }, + + // -- Presence assertion is not flagged -- + { + code: `// case: presence assertion — not an absence check + import { render, screen } from '@testing-library/react' + + test('shows content', () => { + render() + expect(screen.queryByText('exists')).toBeInTheDocument() + }) + `, + }, + + // -- Non-absence matcher is not flagged -- + { + code: `// case: .not with unrelated matcher + import { render, screen } from '@testing-library/react' + + test('no active class', () => { + render() + expect(screen.queryByText('item')).not.toHaveClass('active') + }) + `, + }, + + // -- Non-Testing-Library import not reported -- + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// case: non-TL import — should not report + import { render, screen } from 'other-library' + + test('not TL', () => { + render() + expect(screen.queryByText('gone')).not.toBeInTheDocument() + }) + `, + }, + + // -- Custom module import -- + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// case: custom module, settled + import { render, screen } from 'test-utils' + + test('settled via custom module', async () => { + render() + await screen.findByText('loaded') + expect(screen.queryByText('error')).not.toBeInTheDocument() + }) + `, + }, + + // -- Destructured query after settling -- + { + code: `// case: destructured queryByText after settling + import { render } from '@testing-library/react' + + test('settled destructured', async () => { + const { queryByText, findByText } = render() + await findByText('loaded') + expect(queryByText('error')).not.toBeInTheDocument() + }) + `, + }, + ], + + invalid: [ + // -- Basic: absence before any settling -- + ...SUPPORTED_TESTING_FRAMEWORKS.map( + ([testingFramework, label]) => ({ + code: `// case: ${label} — absence before settling + import { render, screen } from '${testingFramework}' + + test('shows no error', () => { + render() + expect(screen.queryByText('error')).not.toBeInTheDocument() + }) + `, + errors: [ + { + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: 'queryByText' }, + }, + ], + }) + ), + + // -- .not.toBeVisible before settling -- + { + code: `// case: .not.toBeVisible before settling + import { render, screen } from '@testing-library/react' + + test('dialog hidden', () => { + render() + expect(screen.queryByRole('dialog')).not.toBeVisible() + }) + `, + errors: [ + { + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: 'queryByRole' }, + }, + ], + }, + + // -- queryAllBy variant -- + { + code: `// case: queryAllByText before settling + import { render, screen } from '@testing-library/react' + + test('no alerts', () => { + render() + expect(screen.queryAllByText('gone')).not.toBeInTheDocument() + }) + `, + errors: [ + { + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: 'queryAllByText' }, + }, + ], + }, + + // -- Absence BEFORE the await (order matters) -- + { + code: `// case: absence before await — order matters + import { render, screen } from '@testing-library/react' + + test('shows no error', async () => { + render() + expect(screen.queryByText('error')).not.toBeInTheDocument() + await screen.findByText('loaded') + }) + `, + errors: [ + { + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: 'queryByText' }, + }, + ], + }, + + // -- Multiple unsettled absence assertions -- + { + code: `// case: multiple absence assertions before settling + import { render, screen } from '@testing-library/react' + + test('shows nothing', () => { + render() + expect(screen.queryByText('a')).not.toBeInTheDocument() + expect(screen.queryByText('b')).not.toBeInTheDocument() + }) + `, + errors: [ + { + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: 'queryByText' }, + }, + { + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: 'queryByText' }, + }, + ], + }, + + // -- Absence inside awaited waitFor — still a ghost -- + { + code: `// case: absence inside awaited waitFor + import { render, screen, waitFor } from '@testing-library/react' + + test('shows no error', async () => { + render() + await waitFor(() => { + expect(screen.queryByText('error')).not.toBeInTheDocument() + }) + }) + `, + errors: [ + { + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: 'queryByText' }, + }, + ], + }, + + // -- Absence inside unawaited waitFor — still a ghost -- + { + code: `// case: absence inside unawaited waitFor + import { render, screen, waitFor } from '@testing-library/react' + + test('shows no error', () => { + render() + waitFor(() => { + expect(screen.queryByText('error')).not.toBeInTheDocument() + }) + }) + `, + errors: [ + { + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: 'queryByText' }, + }, + ], + }, + + // -- Custom module unsettled -- + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// case: custom module, unsettled + import { render, screen } from 'test-utils' + + test('unsettled', () => { + render() + expect(screen.queryByText('gone')).not.toBeInTheDocument() + }) + `, + errors: [ + { + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: 'queryByText' }, + }, + ], + }, + + // -- .toBeNull (non-negated absence) before settling -- + { + code: `// case: .toBeNull before settling + import { render, screen } from '@testing-library/react' + + test('is null', () => { + render() + expect(screen.queryByText('error')).toBeNull() + }) + `, + errors: [ + { + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: 'queryByText' }, + }, + ], + }, + + // -- .toBeFalsy (non-negated absence) before settling -- + { + code: `// case: .toBeFalsy before settling + import { render, screen } from '@testing-library/react' + + test('is falsy', () => { + render() + expect(screen.queryByText('error')).toBeFalsy() + }) + `, + errors: [ + { + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: 'queryByText' }, + }, + ], + }, + ], +}); From 8454897aa5c65539b8da071c50a64c4dddf98285 Mon Sep 17 00:00:00 2001 From: Josh Miltier Date: Tue, 31 Mar 2026 14:54:53 -0600 Subject: [PATCH 2/6] test: add missing test coverage --- docs/rules/no-unsettled-absence-query.md | 12 +-- src/rules/no-unsettled-absence-query.ts | 2 +- .../rules/no-unsettled-absence-query.test.ts | 99 +++++++++++++++++-- 3 files changed, 99 insertions(+), 14 deletions(-) diff --git a/docs/rules/no-unsettled-absence-query.md b/docs/rules/no-unsettled-absence-query.md index 215c23e3..948edc91 100644 --- a/docs/rules/no-unsettled-absence-query.md +++ b/docs/rules/no-unsettled-absence-query.md @@ -2,7 +2,7 @@ 📝 Disallow absence assertions on `queryBy*` before the component has settled. - + Asserting absence with `queryBy*` + `.not.toBeInTheDocument()` (or `.not.toBeVisible()`) immediately after `render()`, before the component has settled, can produce a false positive. The element isn't there _yet_, not because it _won't_ be there. This is commonly referred to as **Testing Ghosts**. From b6f8e0cf4613cb692b3689a68e9ec4ccfc1c92e6 Mon Sep 17 00:00:00 2001 From: Josh Miltier Date: Tue, 31 Mar 2026 21:40:34 -0600 Subject: [PATCH 5/6] chore: refactor tree-walking to respect function scope boundaries --- src/rules/no-unsettled-absence-query.ts | 134 ++++++++++-------------- 1 file changed, 54 insertions(+), 80 deletions(-) diff --git a/src/rules/no-unsettled-absence-query.ts b/src/rules/no-unsettled-absence-query.ts index 598e1a8c..c658235a 100644 --- a/src/rules/no-unsettled-absence-query.ts +++ b/src/rules/no-unsettled-absence-query.ts @@ -8,6 +8,7 @@ import { isArrowFunctionExpression, isBlockStatement, isCallExpression, + isFunctionDeclaration, isFunctionExpression, isMemberExpression, } from '../node-utils'; @@ -18,10 +19,54 @@ const RULE_NAME = 'no-unsettled-absence-query'; export type MessageIds = 'noUnsettledAbsenceQuery'; export type Options = []; -// Matchers that indicate absence when negated, beyond those already -// covered by helpers.isAbsenceAssert() (which handles PRESENCE_MATCHERS). const NEGATED_ABSENCE_MATCHERS = ['toBeVisible']; +function isNestedFunction(node: TSESTree.Node): boolean { + return ( + isArrowFunctionExpression(node) || + isFunctionExpression(node) || + isFunctionDeclaration(node) + ); +} + +function containsNode( + node: TSESTree.Node, + predicate: (n: TSESTree.Node) => boolean +): boolean { + if (predicate(node)) { + return true; + } + + if (isNestedFunction(node)) { + return false; + } + + for (const key of Object.keys(node)) { + if (key === 'parent') continue; + const child = (node as unknown as Record)[key]; + if (child && typeof child === 'object') { + if (Array.isArray(child)) { + for (const item of child) { + if ( + item && + typeof item === 'object' && + 'type' in item && + containsNode(item as TSESTree.Node, predicate) + ) { + return true; + } + } + } else if ( + 'type' in child && + containsNode(child as TSESTree.Node, predicate) + ) { + return true; + } + } + } + return false; +} + export default createTestingLibraryRule({ name: RULE_NAME, meta: { @@ -62,12 +107,6 @@ export default createTestingLibraryRule({ ); } - /** - * Determines whether a node is inside a callback passed to an async - * Testing Library utility (e.g. waitFor). Absence assertions inside - * these callbacks are always flagged because they can pass on the first - * invocation before the component has settled. - */ function isInsideAsyncUtilCallback(node: TSESTree.Node): boolean { let current: TSESTree.Node | undefined = node.parent; @@ -121,82 +160,23 @@ export default createTestingLibraryRule({ return null; } - function containsAwaitExpression(node: TSESTree.Node): boolean { - if (ASTUtils.isAwaitExpression(node)) { - return true; - } - - for (const key of Object.keys(node)) { - if (key === 'parent') continue; - const child = (node as unknown as Record)[key]; - if (child && typeof child === 'object') { - if (Array.isArray(child)) { - for (const item of child) { - if ( - item && - typeof item === 'object' && - 'type' in item && - containsAwaitExpression(item as TSESTree.Node) - ) { - return true; - } - } - } else if ( - 'type' in child && - containsAwaitExpression(child as TSESTree.Node) - ) { - return true; - } - } - } - return false; - } - - function containsGetQueryCall(node: TSESTree.Node): boolean { - if (ASTUtils.isIdentifier(node) && helpers.isGetQueryVariant(node)) { - return true; - } - - for (const key of Object.keys(node)) { - if (key === 'parent') continue; - const child = (node as unknown as Record)[key]; - if (child && typeof child === 'object') { - if (Array.isArray(child)) { - for (const item of child) { - if ( - item && - typeof item === 'object' && - 'type' in item && - containsGetQueryCall(item as TSESTree.Node) - ) { - return true; - } - } - } else if ( - 'type' in child && - containsGetQueryCall(child as TSESTree.Node) - ) { - return true; - } - } - } - return false; - } - function hasSettlingExpression(statement: TSESTree.Statement): boolean { - return ( - containsAwaitExpression(statement) || containsGetQueryCall(statement) + const hasAwait = containsNode(statement, (n) => + ASTUtils.isAwaitExpression(n) + ); + const hasGetQuery = containsNode( + statement, + (n) => ASTUtils.isIdentifier(n) && helpers.isGetQueryVariant(n) ); + return hasAwait || hasGetQuery; } return { 'CallExpression Identifier'(node: TSESTree.Identifier) { - // Only interested in queryBy* / queryAllBy* variants if (!helpers.isQueryQueryVariant(node)) { return; } - // Must be inside an expect() call const expectCallNode = findClosestCallNode(node, 'expect'); if ( !expectCallNode?.parent || @@ -205,14 +185,10 @@ export default createTestingLibraryRule({ return; } - // Must be an absence assertion if (!isAbsenceAssertion(expectCallNode.parent)) { return; } - // Absence assertions inside async util callbacks (e.g. waitFor) are - // always flagged - they pass on the first invocation before the - // component has settled. if (isInsideAsyncUtilCallback(node)) { context.report({ node, @@ -222,8 +198,6 @@ export default createTestingLibraryRule({ return; } - // Find the enclosing function body and determine whether a settling - // expression appears on any preceding statement. const functionBody = findEnclosingFunctionBody(node); if (!functionBody) { return; From e11dfcf8a734ba0d3bfeaa00f29fb916f0014a04 Mon Sep 17 00:00:00 2001 From: Josh Miltier Date: Wed, 1 Apr 2026 08:03:10 -0600 Subject: [PATCH 6/6] test: add testing for dom and marko (post support) --- .../rules/no-unsettled-absence-query.test.ts | 158 +++++++++--------- 1 file changed, 76 insertions(+), 82 deletions(-) diff --git a/tests/rules/no-unsettled-absence-query.test.ts b/tests/rules/no-unsettled-absence-query.test.ts index 09058cf8..a4ba7a25 100644 --- a/tests/rules/no-unsettled-absence-query.test.ts +++ b/tests/rules/no-unsettled-absence-query.test.ts @@ -16,20 +16,21 @@ type RuleValidTestCase = ValidTestCase; type RuleInvalidTestCase = InvalidTestCase; const SUPPORTED_TESTING_FRAMEWORKS = [ + ['@testing-library/dom', 'DOM TL'], ['@testing-library/react', 'React TL'], ['@testing-library/vue', 'Vue TL'], ['@testing-library/angular', 'Angular TL'], + ['@marko/testing-library', 'Marko TL'], ] as const; ruleTester.run('no-unsettled-absence-query', rule, { valid: [ - // -- Settled by findBy* -- ...SUPPORTED_TESTING_FRAMEWORKS.map( ([testingFramework, label]) => ({ - code: `// case: ${label} - settled by findBy + code: ` import { render, screen } from '${testingFramework}' - test('shows no error', async () => { + test('${label} - settled by findBy', async () => { render() await screen.findByText('loaded') expect(screen.queryByText('error')).not.toBeInTheDocument() @@ -38,12 +39,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { }) ), - // -- Settled by waitFor -- { - code: `// case: settled by waitFor + code: ` import { render, screen, waitFor } from '@testing-library/react' - test('shows no error', async () => { + test('settled by waitFor', async () => { render() await waitFor(() => expect(something).toBe(true)) expect(screen.queryByRole('alert')).not.toBeInTheDocument() @@ -51,24 +51,22 @@ ruleTester.run('no-unsettled-absence-query', rule, { `, }, - // -- Settled by act -- { - code: `// case: settled by act wrapping render + code: ` import { render, screen, act } from '@testing-library/react' - test('shows no error', async () => { + test('settled by act wrapping render', async () => { await act(() => render()) expect(screen.queryByText('error')).not.toBeInTheDocument() }) `, }, - // -- Settled by getBy* -- { - code: `// case: settled by getByText + code: ` import { render, screen } from '@testing-library/react' - test('shows no error', () => { + test('settled by getByText', () => { render() screen.getByText('visible heading') expect(screen.queryByText('error')).not.toBeInTheDocument() @@ -76,12 +74,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { `, }, - // -- Settled by getBy* inside expect -- { - code: `// case: settled by getByRole inside expect assertion + code: ` import { render, screen } from '@testing-library/react' - test('shows no error', () => { + test('settled by getByRole inside expect', () => { render() expect(screen.getByRole('heading')).toBeVisible() expect(screen.queryByText('error')).not.toBeInTheDocument() @@ -89,12 +86,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { `, }, - // -- Settled by getAllBy* -- { - code: `// case: settled by getAllByText + code: ` import { render, screen } from '@testing-library/react' - test('shows no alerts', () => { + test('settled by getAllByText', () => { render() screen.getAllByText('item') expect(screen.queryByRole('alert')).not.toBeVisible() @@ -102,12 +98,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { `, }, - // -- .not.toBeVisible after settling -- { - code: `// case: .not.toBeVisible after findBy + code: ` import { render, screen } from '@testing-library/react' - test('dialog is hidden', async () => { + test('.not.toBeVisible after findBy', async () => { render() await screen.findByText('loaded') expect(screen.queryByRole('dialog')).not.toBeVisible() @@ -115,50 +110,46 @@ ruleTester.run('no-unsettled-absence-query', rule, { `, }, - // -- Presence assertion is not flagged -- { - code: `// case: presence assertion - not an absence check + code: ` import { render, screen } from '@testing-library/react' - test('shows content', () => { + test('presence assertion is not flagged', () => { render() expect(screen.queryByText('exists')).toBeInTheDocument() }) `, }, - // -- Non-absence matcher is not flagged -- { - code: `// case: .not with unrelated matcher + code: ` import { render, screen } from '@testing-library/react' - test('no active class', () => { + test('.not with unrelated matcher is not flagged', () => { render() expect(screen.queryByText('item')).not.toHaveClass('active') }) `, }, - // -- Non-Testing-Library import not reported -- { settings: { 'testing-library/utils-module': 'test-utils' }, - code: `// case: non-TL import - should not report + code: ` import { render, screen } from 'other-library' - test('not TL', () => { + test('non-TL import is not reported', () => { render() expect(screen.queryByText('gone')).not.toBeInTheDocument() }) `, }, - // -- Custom module import -- { settings: { 'testing-library/utils-module': 'test-utils' }, - code: `// case: custom module, settled + code: ` import { render, screen } from 'test-utils' - test('settled via custom module', async () => { + test('custom module settled', async () => { render() await screen.findByText('loaded') expect(screen.queryByText('error')).not.toBeInTheDocument() @@ -166,12 +157,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { `, }, - // -- Destructured query after settling -- { - code: `// case: destructured queryByText after settling + code: ` import { render } from '@testing-library/react' - test('settled destructured', async () => { + test('destructured queryByText after settling', async () => { const { queryByText, findByText } = render() await findByText('loaded') expect(queryByText('error')).not.toBeInTheDocument() @@ -179,33 +169,30 @@ ruleTester.run('no-unsettled-absence-query', rule, { `, }, - // -- Standalone queryBy (no expect wrapper) -- { - code: `// case: queryBy used standalone - no expect wrapper + code: ` import { render, screen } from '@testing-library/react' - test('standalone query', () => { + test('standalone queryBy without expect', () => { render() const el = screen.queryByText('error') }) `, }, - // -- Top-level assertion (no enclosing function body) -- { - code: `// case: top-level assertion - no enclosing function body + code: ` import { screen } from '@testing-library/react' expect(screen.queryByText('error')).not.toBeInTheDocument() `, }, - // -- Settled assertion inside function expression callback -- { - code: `// case: settled assertion inside function expression + code: ` import { render, screen } from '@testing-library/react' - test('settled in function expression', async function() { + test('settled inside function expression', async function() { render() await screen.findByText('loaded') expect(screen.queryByText('error')).not.toBeInTheDocument() @@ -213,12 +200,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { `, }, - // -- Settled by await nested in preceding expect arguments -- { - code: `// case: settled by await nested inside expect arguments + code: ` import { render, screen } from '@testing-library/react' - test('await inside expect args', async () => { + test('settled by await nested inside expect arguments', async () => { render() expect(await screen.findByText('loaded')).toBeInTheDocument() expect(screen.queryByText('error')).not.toBeInTheDocument() @@ -228,13 +214,12 @@ ruleTester.run('no-unsettled-absence-query', rule, { ], invalid: [ - // -- Basic: absence before any settling -- ...SUPPORTED_TESTING_FRAMEWORKS.map( ([testingFramework, label]) => ({ - code: `// case: ${label} - absence before settling + code: ` import { render, screen } from '${testingFramework}' - test('shows no error', () => { + test('${label} - absence before settling', () => { render() expect(screen.queryByText('error')).not.toBeInTheDocument() }) @@ -250,12 +235,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { }) ), - // -- .not.toBeVisible before settling -- { - code: `// case: .not.toBeVisible before settling + code: ` import { render, screen } from '@testing-library/react' - test('dialog hidden', () => { + test('.not.toBeVisible before settling', () => { render() expect(screen.queryByRole('dialog')).not.toBeVisible() }) @@ -270,12 +254,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { ], }, - // -- queryAllBy variant -- { - code: `// case: queryAllByText before settling + code: ` import { render, screen } from '@testing-library/react' - test('no alerts', () => { + test('queryAllByText before settling', () => { render() expect(screen.queryAllByText('gone')).not.toBeInTheDocument() }) @@ -290,12 +273,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { ], }, - // -- Absence BEFORE the await (order matters) -- { - code: `// case: absence before await - order matters + code: ` import { render, screen } from '@testing-library/react' - test('shows no error', async () => { + test('absence before await - order matters', async () => { render() expect(screen.queryByText('error')).not.toBeInTheDocument() await screen.findByText('loaded') @@ -311,12 +293,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { ], }, - // -- Multiple unsettled absence assertions -- { - code: `// case: multiple absence assertions before settling + code: ` import { render, screen } from '@testing-library/react' - test('shows nothing', () => { + test('multiple absence assertions before settling', () => { render() expect(screen.queryByText('a')).not.toBeInTheDocument() expect(screen.queryByText('b')).not.toBeInTheDocument() @@ -338,12 +319,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { ], }, - // -- Absence inside awaited waitFor - still a ghost -- { - code: `// case: absence inside awaited waitFor + code: ` import { render, screen, waitFor } from '@testing-library/react' - test('shows no error', async () => { + test('absence inside awaited waitFor', async () => { render() await waitFor(() => { expect(screen.queryByText('error')).not.toBeInTheDocument() @@ -360,12 +340,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { ], }, - // -- Absence inside unawaited waitFor - still a ghost -- { - code: `// case: absence inside unawaited waitFor + code: ` import { render, screen, waitFor } from '@testing-library/react' - test('shows no error', () => { + test('absence inside unawaited waitFor', () => { render() waitFor(() => { expect(screen.queryByText('error')).not.toBeInTheDocument() @@ -382,13 +361,12 @@ ruleTester.run('no-unsettled-absence-query', rule, { ], }, - // -- Custom module unsettled -- { settings: { 'testing-library/utils-module': 'test-utils' }, - code: `// case: custom module, unsettled + code: ` import { render, screen } from 'test-utils' - test('unsettled', () => { + test('custom module unsettled', () => { render() expect(screen.queryByText('gone')).not.toBeInTheDocument() }) @@ -403,12 +381,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { ], }, - // -- .toBeNull (non-negated absence) before settling -- { - code: `// case: .toBeNull before settling + code: ` import { render, screen } from '@testing-library/react' - test('is null', () => { + test('.toBeNull before settling', () => { render() expect(screen.queryByText('error')).toBeNull() }) @@ -423,12 +400,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { ], }, - // -- .toBeFalsy (non-negated absence) before settling -- { - code: `// case: .toBeFalsy before settling + code: ` import { render, screen } from '@testing-library/react' - test('is falsy', () => { + test('.toBeFalsy before settling', () => { render() expect(screen.queryByText('error')).toBeFalsy() }) @@ -443,12 +419,11 @@ ruleTester.run('no-unsettled-absence-query', rule, { ], }, - // -- Absence inside waitFor with function expression callback -- { - code: `// case: absence inside waitFor with function expression callback + code: ` import { render, screen, waitFor } from '@testing-library/react' - test('waitFor function expression', async () => { + test('absence inside waitFor with function expression callback', async () => { render() await waitFor(function() { expect(screen.queryByText('error')).not.toBeInTheDocument() @@ -465,9 +440,8 @@ ruleTester.run('no-unsettled-absence-query', rule, { ], }, - // -- Unsettled in function expression test callback -- { - code: `// case: unsettled in function expression + code: ` import { render, screen } from '@testing-library/react' test('unsettled in function expression', function() { @@ -484,5 +458,25 @@ ruleTester.run('no-unsettled-absence-query', rule, { }, ], }, + + { + code: ` + import { render, screen } from '@testing-library/react' + + test('await inside nested function does not settle outer scope', () => { + render() + const helper = async () => { await screen.findByText('loaded') } + expect(screen.queryByText('error')).not.toBeInTheDocument() + }) + `, + errors: [ + { + messageId: 'noUnsettledAbsenceQuery', + data: { queryMethod: 'queryByText' }, + line: 7, + column: 19, + }, + ], + }, ], });