Skip to content
Open
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | | |
Expand Down
86 changes: 86 additions & 0 deletions docs/rules/no-unsettled-absence-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# testing-library/no-unsettled-absence-query

📝 Disallow absence assertions on `queryBy*` before the component has settled.

<!-- end auto-generated rule header -->

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(<AsyncComponent />);
expect(screen.queryByText('error')).not.toBeInTheDocument();
});

// Absence assertion BEFORE the await - order matters
test('shows no error', async () => {
render(<AsyncComponent />);
expect(screen.queryByText('error')).not.toBeInTheDocument();
await screen.findByText('loaded');
});

// queryAllBy variant
test('shows no alerts', () => {
render(<AsyncComponent />);
expect(screen.queryAllByRole('alert')).not.toBeInTheDocument();
});

// Absence assertion inside waitFor - passes on first retry, still a ghost
test('shows no error', async () => {
render(<AsyncComponent />);
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(<AsyncComponent />);
await screen.findByText('loaded');
expect(screen.queryByText('error')).not.toBeInTheDocument();
});

// waitFor settles the component first
test('shows no error', async () => {
render(<AsyncComponent />);
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(<AsyncComponent />));
expect(screen.queryByText('error')).not.toBeInTheDocument();
});

// getBy* proves sync render completed
test('shows no error', () => {
render(<Component />);
screen.getByText('visible heading');
expect(screen.queryByText('error')).not.toBeInTheDocument();
});
```

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We need a "Limitations" section to mention that some untracked helpers (custom setup, fake timers, etc) might produce false positives.

## 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)
2 changes: 2 additions & 0 deletions src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Loading