skip to content

Your Playwright check that the export spinner is gone passes even when the statement page fails to load — why?

level: middleimportance: must knowfreq 63%

answer

  1. When does the polling loop stop?
  2. Already true at time zero
  3. The page may never have rendered
  4. Something positive must come first
  5. Anchor, act, then negate

basics

~10 s

A negated web-first assertion stops retrying the moment its condition is true, and it is already true on a page that never rendered the spinner. The check passes at time zero and proves nothing.

solid answer

~40 s

Retrying a negation is not the mirror image of retrying a positive check. `await expect(spinner).toBeVisible()` starts false and waits for the application to make it true; `await expect(spinner).not.toBeVisible()` starts *true* on an empty page, so it returns on its first poll. Playwright treats a locator that matches nothing as not visible on purpose, so that absence checks work for removed elements — the cost is that a page which rendered an error shell satisfies the check just as well as a completed export. The fix is ordering, not the timeout: put a positive anchor first, such as `await expect(page.getByRole('table', { name: 'Transactions' })).toBeVisible()` and `await expect(spinner).toBeVisible()`, and only then assert the spinner is gone.

code

typescript · 14 lines
typescript
import { expect, test } from '@playwright/test';

test('the balance refresh spinner clears', async ({ page }) => {
  await page.goto('/statements/2026-08');
  const spinner = page.getByTestId('balance-spinner');

  // Anchors: the page really rendered, and the refresh really started.
  await expect(page.getByTestId('balance-widget')).toBeVisible();
  await page.getByRole('button', { name: 'Refresh balance' }).click();
  await expect(spinner).toBeVisible();

  // Only now does the negation carry information.
  await expect(spinner).not.toBeVisible();
});

go deeper

for a junior

Learn the shape of the fix before the theory: assert the thing is there, do the action, then assert it is gone. A test whose only assertion is an absence check is not yet finished.

for a middle

Explain the asymmetry in the retry loop. A positive check waits for false to become true; a negation is already true on an empty page, so it returns on its first poll instead of waiting for anything.

for a senior

Demonstrate the diagnosis: ask whether the test would still pass against a blank page, then choose an anchor strong enough to make the answer no, without making the suite brittle on fast transitions.

for a principal

Frame it as a suite-level evidence problem. Decide what an assertion must prove before it counts as coverage, and put that rule where it is enforced, because absence checks fail silently for months at a time.

## Why the loop runs the other way A web-first assertion polls until its condition is **true**, then stops. For a positive check such as `await expect(spinner).toBeVisible()` that is a genuine wait: the condition starts false on a blank page and turns true only once the application does something. For `await expect(spinner).not.toBeVisible()` the same loop runs — but the starting state of a page that rendered nothing already satisfies it. The assertion returns on its first poll, in about a millisecond, and the test moves on. That is not a defect in the matcher. Playwright deliberately treats an empty locator result as *not visible*, so that absence checks work for elements that were removed from the DOM rather than merely styled away. The price is that the check cannot tell these two worlds apart: - the spinner appeared, the export finished, the spinner was removed - the statement page never loaded, so there was no spinner and no export ## The timeline of a false green 1. The test navigates to the statement page. The API returns 500, and the app renders an error shell instead of the balance widget and the transaction table. 2. `page.getByTestId('export-spinner')` matches zero elements. Nothing has failed yet — locators are lazy and resolve only when something uses them. 3. `await expect(spinner).not.toBeVisible()` resolves the locator, finds nothing, treats that as not visible, and passes immediately. 4. The test ends green. Nothing in the report hints that the export never ran; the trace shows a passing assertion evaluated against an empty result. Raising the `expect` timeout changes none of this, because the assertion never waited in the first place. Nor does swapping in `toBeHidden()`, which has identical empty-result behaviour. ## Anchors, and how much each one proves A **positive anchor** is an assertion that can only hold if the application really reached the state you are about to watch it leave. Anchors are not equal: | Anchor | Proves | Weakness | |---|---|---| | `page.goto()` resolved | a response arrived | an error page resolves just as happily | | `await expect(table).toBeVisible()` | the statement view rendered | says nothing about the export itself | | `await expect(spinner).toBeVisible()` | the export actually started | can be missed if the work is very fast | | `await expect(rows).toHaveCount(13)` | the data rendered with a known shape | needs a stable fixture | The strongest anchor for "X went away" is "X was there": assert the spinner visible, then assert it hidden. Its one hazard is a transition too quick to observe, where the anchor times out against a perfectly healthy application. When that happens, do not delete the anchor — move it to something durable, such as the export button becoming disabled, or the row count changing. ## The pattern to write - **Anchor.** Assert something positive about the state before the action. - **Act.** Click the export button, delete the transaction, apply the filter. - **Assert the effect positively.** A count, a text, a URL — something that is false on an unrendered page. - **Then negate.** Use the absence check to narrow what the positive assertions already established, never as the only assertion in the test. A useful review question: *if the application rendered a blank page, would this test still pass?* Every naked absence assertion answers yes, and every test that answers yes is testing nothing. ## Where negations are still exactly right None of this makes negated assertions bad. Several real requirements can only be written as absence: - an error banner must not appear after a valid transfer - a delete-confirmation dialog must close once confirmed - an admin-only export button must not be offered to an ordinary user Each of those still needs an anchor proving the page under test was the page you meant. For the admin case the anchor is that the statement rendered for the ordinary user at all: assert the balance widget is visible, and only then assert the admin export control is not. ## What does not fix it - **A longer timeout.** It extends how long a negation may wait for something already true. - **A retry of the whole test.** A vacuous pass is deterministic; it will pass again. - **A second absence check.** Two negations anchored to each other still both pass on an empty page. - **Switching matcher spelling.** `toBeHidden()`, `not.toBeVisible()` and `toBeVisible({ visible: false })` behave the same way here.

  • Should the positive anchor go before the action or after it?
    Both places earn their keep. An anchor before the action proves the page rendered the state you are about to change; a positive assertion after it proves the action had an effect. The absence check comes last and only narrows what those two already established.
  • Does raising the expect timeout ever rescue an absence check?
    No. It only extends how long a negation may keep waiting for a condition that is already satisfied, so a vacuous pass stays vacuous. Timeouts matter for the positive anchor instead — that is the assertion which genuinely has to wait for real work.
  • The anchor assertion on a fast spinner times out on a healthy app. Now what?
    Move the anchor rather than deleting it. Anchor on something durable that the flow must produce: the export button entering a disabled state, a request the test awaits, or a row count that changes. Deleting the anchor restores the vacuous pass you were trying to remove.

A metal detector that beeps only for metal stays silent in an empty field and stays silent when its battery is flat. The silence alone cannot tell you which situation you are in.

saying these in an interview costs you the question

  • Says a longer expect timeout makes an absence check more reliable.
  • Believes a negated assertion waits for the element to appear first.
  • Anchors an absence check with another absence check.
  • Treats a green suite as proof the flow actually ran.
  • Thinks a resolved page.goto proves the page rendered its content.