skip to content

In Playwright, what happens to expect(locator).not.toHaveText('Pending') when the element is missing?

level: middleimportance: should knowfreq 37%

answer

  1. Not every negation treats absence alike
  2. Which family gets the carve-out?
  3. Content matchers need a resolved element
  4. Watch the call log, not the diff
  5. Counting is the absence tool

basics

~20 s

It does not pass. Playwright only treats a missing element as success for visibility and attachment checks, so the content negation keeps retrying and fails at the timeout with a log showing no element was ever found.

solid answer

~40 s

In Playwright 1.63 the empty-result carve-out is narrow: `toBeHidden()`, `not.toBeVisible()` and `not.toBeAttached()` all pass when the locator matches nothing, but a content matcher needs an element to read a property from. `not.toHaveText('Pending')` therefore treats an empty result as a non-match, keeps polling, and fails at the `expect` timeout with a call log showing the locator resolving to nothing rather than a text mismatch. That is deliberate — if absence satisfied it, every typo'd test id and every failed page load would make the assertion green. So `not.toHaveText` is a claim that the element exists and says something else; it is not a way to assert a row was removed. For removal, use `toHaveCount(0)` on the locator or `not.toBeAttached()`.

code

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

test('a settled transaction drops its pending badge', async ({ page }) => {
  await page.goto('/statements/2026-08');

  const row = page.getByRole('row').filter({ hasText: 'Coffee Roasters' });

  // Needs the row to exist: this times out if the row never renders.
  await expect(row).toBeVisible();
  await expect(row).not.toHaveText(/Pending/);

  // Absence of a row is a different question and a different matcher.
  await expect(page.getByRole('row').filter({ hasText: 'Voided' })).toHaveCount(0);
});

go deeper

for a junior

Take away the rule of thumb: use text negations only for elements you expect to be on the page, and use a count assertion when what you mean is that something is not there at all.

for a middle

Explain the split between matchers that ask whether an element is present and matchers that read a property from one. Only the first group has a defined answer for an empty locator result.

for a senior

Show how the failure reads in practice — a timeout whose call log says nothing was found — and how you keep that legible by putting a positive existence assertion in front of every content negation.

for a principal

Argue the design: a carve-out for content matchers would make them unfalsifiable, so every broken locator would report success. That is the tradeoff to defend when someone proposes making negations uniformly lenient.

## Two families of matcher Playwright's locator assertions fall into two groups. One asks **whether an element is there** — `toBeVisible`, `toBeHidden`, `toBeAttached`, `toHaveCount`. The other reads a **property off a resolved element**: its text, value, attribute, class or computed CSS. The first group can answer a question about a page with nothing on it. The second cannot: with no element there is no text to compare against `'Pending'`. That split is what makes negation asymmetric, and in Playwright 1.63 it shows up directly in behaviour: - `await expect(locator).not.toBeVisible()` passes when the locator matches nothing. - `await expect(locator).toBeHidden()` passes when the locator matches nothing. - `await expect(locator).not.toBeAttached()` passes when the locator matches nothing. - `await expect(locator).not.toHaveText('Pending')` **does not** pass when the locator matches nothing. ## What actually happens on a missing element 1. The assertion enters its retry loop and resolves the locator. 2. The result is empty, so there is no element and no text to read from it. 3. Playwright does not translate "no element" into "the text differs". The poll is recorded as a non-match, and the loop continues. 4. At the `expect` timeout the assertion fails, and the call log shows the locator resolving to nothing on attempt after attempt rather than a text comparison. The failure you get is therefore a timeout whose log says the element was never found — which reads as a puzzle precisely when the author believed absence would satisfy the negation. ## Which matchers carve out the empty case | Assertion | Locator matches nothing | |---|---| | `toBeHidden()` | passes | | `not.toBeVisible()` | passes | | `not.toBeAttached()` | passes | | `toHaveCount(0)` | passes | | `not.toHaveText('Pending')` | retries, then fails | | `not.toHaveValue('42')` | retries, then fails | `toHaveCount` sits with the passing rows for a different reason from the others: it is evaluated over the **whole matched set** rather than a single resolved element, and an empty set has a perfectly good answer to "how many?" — zero. ## Why the asymmetry is the right design Extending the carve-out to content matchers would make them unfalsifiable. If `not.toHaveText('Pending')` passed on an empty result, then every typo in a test id, every locator broken by a refactor, and every page that failed to load would satisfy it. The assertion would be green in exactly the situations a test exists to catch. Requiring an element keeps the negation honest: it says *this element exists and its text is not that*, which is a claim about the application rather than about the absence of one. The presence family needs the opposite treatment. An assertion that something is gone has to accept the case where the node was removed from the DOM, otherwise it could never express removal at all. ## Writing the check you meant - **The element must exist and its content must differ** — `not.toHaveText(...)`, ideally after a positive assertion that the element is there, so a broken locator fails with a clear error instead of a confusing one. - **The element must not exist** — `toHaveCount(0)` on the locator, or `not.toBeAttached()` on a single node. - **The element must not be shown, mounted or not** — `toBeHidden()`. On a bank statement page: to check that a settled transaction no longer carries a pending badge, assert the row is visible, then `not.toHaveText(/Pending/)` on it. To check that a voided transaction is not listed at all, filter the row locator and assert `toHaveCount(0)` — and anchor that with a positive count over the whole table, since zero rows is also what a page that never rendered produces.

  • How would you assert the balance widget exists but no longer shows a pending marker?
    Anchor on existence, then negate the content: `await expect(balance).toBeVisible()` followed by `await expect(balance).not.toHaveText(/Pending/)`. The first assertion supplies the element the second needs, so a broken locator fails with a clear visibility error instead of a confusing not-found timeout.
  • Why does toHaveCount(0) behave differently from not.toHaveText() on an empty page?
    `toHaveCount` is evaluated over the whole matched set rather than one resolved element, so an empty set answers the question — the count is zero. A text matcher has to read a property off an element, and with no element there is simply no value to compare.

saying these in an interview costs you the question

  • Assumes every negated matcher passes when the element is missing.
  • Uses not.toHaveText to assert that a row was removed.
  • Reads an element-not-found timeout as a text mismatch.
  • Thinks the carve-out covers everything written with not.
  • Believes not.toHaveText throws instantly on an empty locator.