A React Testing Library test clicks Save and then asserts `expect(screen.queryByText('Saving…')).not.toBeInTheDocument()`. Why can that assertion pass even when the spinner logic is broken, and what does waitForElementToBeRemoved give you instead?
answer
- "not yet" looks exactly like "not any more"
- the assertion would pass with the feature deleted
- prove presence before you wait for absence
- the outcome is a positive assertion
basics
~20 sThe assertion runs before the spinner has even mounted, so it passes by asserting the absence of something that was never present — a vacuous green. waitForElementToBeRemoved throws if the element is missing at call time, forcing the test to prove it appeared and then went away.
solid answer
~40 sAsserting a negative about an asynchronous UI is the trap: `queryByText` returns null the instant after the click because the spinner has not rendered yet, and `not.toBeInTheDocument()` is perfectly happy with that. The test would pass if you deleted the spinner entirely, or misspelled the text, or broke the whole save path. `waitForElementToBeRemoved` closes both holes — it throws immediately with an explicit error if the element is not present when you call it, and then waits for it to disappear, so a pass means "it was there and then it wasn't". Often the sharper alternative is to skip the spinner and await the settled outcome instead — `await screen.findByText('Saved')` — because the loading state is an implementation detail, while the result is what the user is waiting for.
code
javascript · 13 linesimport { render, screen, waitForElementToBeRemoved } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SaveButton from './SaveButton'
test('clears the saving indicator when the save completes', async () => {
const user = userEvent.setup()
render(<SaveButton />)
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitForElementToBeRemoved(() => screen.queryByText('Saving…'))
expect(screen.getByText('Saved')).toBeInTheDocument()
})go deeper
Know that queryBy returns null both when an element has not appeared yet and when it has gone, so a negative assertion straight after a click proves nothing.
Explain what waitForElementToBeRemoved adds — it throws when the element is absent at call time, then waits for removal — and why the callback form beats a captured element reference in React.
Show the judgment call: most loading assertions should be replaced by awaiting the settled outcome, and an "already removed" failure is feedback about the mock's timing rather than about the component.
Generalise it into a review rule for the suite: unanchored negative assertions are vacuous by construction. Decide where loading states are worth testing at all versus being implementation detail the outcome assertion already covers.
## Negative assertions have no notion of "yet" `expect(queryByX(...)).not.toBeInTheDocument()` asks one question at one instant: is this element in the DOM right now? For anything asynchronous, "not yet" and "not any more" produce identical answers. That is the entire defect. Immediately after a click, an async save has not yet committed its loading render, so the query finds nothing and the assertion passes — while proving nothing at all. The test is worse than useless because it looks thorough. It survives deleting the spinner, renaming its text, breaking the save handler, and leaving the spinner up forever. A vacuous pass is a test that will never go red for the bug it was written to catch. ## What waitForElementToBeRemoved adds The helper encodes the two-phase claim the test actually wants to make: ```js await user.click(screen.getByRole('button', { name: 'Save' })) await waitForElementToBeRemoved(() => screen.queryByText('Saving…')) ``` On the first call it evaluates the callback, and **if the element is already absent it throws** with an explicit message saying the element must exist before you can wait for its removal. That converts the silent vacuous pass into a loud failure. Then it waits, on the same retry machinery as `waitFor`, until the element disappears or the timeout expires. Two call forms exist. Passing an **element** works when you have already captured it and it will not be replaced. Passing a **callback** that re-queries is safer in React, where a re-render can swap the node for a different instance carrying the same text — with a stale element reference you may end up waiting on a node that was detached long ago while a fresh one is still on screen. ## The ordering problem it does not solve Even with the right helper, you have to observe the loading state before it ends. If saving resolves in under a millisecond because the network is mocked with an already-resolved promise, the spinner may never be observable, and the helper will fail on "already removed" — a false *red*. That is a signal about the test's seam, not about the component: either the mock should introduce a controllable delay so the loading state is genuinely reachable, or the loading state does not deserve an assertion here. ## Often the right move: assert the outcome Ask what the test is protecting. If the answer is "the user sees confirmation after saving", then the spinner is a step along the way and the confirmation is the contract: ```js await user.click(screen.getByRole('button', { name: 'Save' })) expect(await screen.findByText('Saved')).toBeInTheDocument() ``` This is positive, it cannot pass vacuously — the text has to actually appear — and it survives the team replacing a spinner with a progress bar or a skeleton. Wait for the removal only when the disappearance itself is the behaviour under test: a dismissible toast, a modal that must close after confirm, an overlay that must not stay stuck. ## The general rule for negative assertions A negative assertion is only meaningful when it is anchored to a moment you have established. Two shapes are trustworthy: - **After a positive await.** `await screen.findByText('Saved')` first, then `expect(screen.queryByText('Saving…')).not.toBeInTheDocument()`. The settled state is now established, so the negative means something. - **Inside an explicit removal wait**, as above, where presence is verified before absence is awaited. What is never trustworthy is a bare negative fired straight after an action. The same reasoning applies to asserting that an error message is absent, that a row was deleted, or that a request was not sent: check at a moment you have proved the system reached, not at an arbitrary instant on the way there.
- Should you pass an element or a callback to waitForElementToBeRemoved in a React test?Prefer the callback that re-queries. React can replace a node on re-render while the same text stays on screen, and a captured element reference would then be detached — you would be waiting on a node that is gone while the user still sees the spinner. The callback re-evaluates each retry and always reflects the current DOM.
- The helper fails with "element already removed" because the mocked request resolves instantly. What do you do?Treat it as a message about the seam. Either give the network mock a controllable delay so the loading state is genuinely reachable, or drop the loading assertion and await the settled outcome instead. Do not switch back to a bare negative assertion — that only makes the test stop reporting.
- When is a bare `expect(queryBy...).not.toBeInTheDocument()` actually sound?When it is anchored to a moment you have already established. Await a positive condition first — `await screen.findByText('Saved')` — and then assert the absence; the settled state is now proven, so the negative carries information. Fired immediately after an action, it only tells you the UI has not caught up yet.
saying these in an interview costs you the question
- Asserting absence immediately after triggering async work
- Believing a passing negative assertion proves the element disappeared
- Passing a stale element reference after a re-render
- Adding a sleep before the negative assertion to "be safe"
- Testing the spinner when the outcome is what users care about