skip to content

Automatic Retrying

The matchers that re-check the page until they pass or the expect timeout expires. Interviewers ask because this is where the framework puts the wait that older tools make you write by hand.

on this pageshow

explore

questions

9

In Playwright, how does expect(locator).toBeHidden() differ from expect(locator).not.toBeVisible()?

level: juniorimportance: must knowfreq 58%

answer

  1. Both are retrying assertions
  2. Think about the empty locator result
  3. One reads positive, one reads negated
  4. Compare the two failure lines
  5. What neither of them proves

basics

~20 s

In outcome they are the same. Both retry until the element is hidden or absent from the DOM, and both pass when it never existed at all. The difference is readability and the failure message, not semantics.

solid answer

~40 s

They are two spellings of the same web-first check. Both poll the locator until the `expect` timeout expires, and both treat an empty result as success: Playwright special-cases the missing element so that `toBeHidden()` passes and `not.toBeVisible()` passes when the locator matches nothing. What differs is the report — `toBeHidden()` fails with an expected/received pair phrased around hidden versus visible, the negated form with an expectation phrased as *not visible* — and readability. On a bank statement page, `await expect(page.getByTestId('export-spinner')).toBeHidden()` beats the double negative. The point worth making in an interview is what neither matcher tells you: that the spinner was ever there. Both are satisfied by a page that failed to render, so pair either one with a positive anchor.

code

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

test('export spinner clears once the CSV is ready', async ({ page }) => {
  await page.goto('/statements/2026-08');
  await expect(page.getByRole('table', { name: 'Transactions' })).toBeVisible();

  await page.getByRole('button', { name: 'Export CSV' }).click();
  await expect(page.getByTestId('export-spinner')).toBeVisible();
  await expect(page.getByTestId('export-spinner')).toBeHidden();
});

go deeper

for a junior

Remember that both spellings pass for a hidden element and for one that is missing entirely, and that Playwright retries them rather than checking once. Being able to say that plainly is enough at this stage.

for a middle

Explain the empty-result rule: on each poll the locator is resolved, and an empty result is reported as hidden, so the check succeeds without the element ever having existed. Mention that only the failure messages differ.

for a senior

Show that you standardise on one spelling across the suite and that you never let an absence check stand alone, because a page that failed to render satisfies it. Name the positive anchor you would put in front of it.

for a principal

Own the convention rather than the trivia. A review rule that every absence assertion sits next to a positive one costs far less than triaging a suite that has been green for months for the wrong reason.

## Two spellings of one check `expect(locator).toBeHidden()` and `expect(locator).not.toBeVisible()` are both **web-first assertions**. Playwright re-resolves the locator and re-evaluates the condition in a polling loop until it holds or the `expect` timeout expires (5 seconds unless the project config raises it). Neither takes a single snapshot of the page and judges it once, which is why neither needs a manual wait in front of it, and why a failure arrives as a timeout with a call log rather than as an instant mismatch. The condition they share is Playwright's own definition of visibility, which is **not** the CSS `visibility` property. An element is visible when it is attached to the document, has a non-empty bounding box, and is not `visibility: hidden`. Everything else counts as hidden: - `display: none` on the element or on any ancestor - `visibility: hidden`, set directly or inherited - a bounding box of zero width or zero height, such as an empty inline `<span>` - **nothing matching the locator at all** Two things deliberately do *not* make an element hidden: `opacity: 0`, and being scrolled outside the viewport. Both are visible as far as these matchers are concerned, so an export spinner faded to zero opacity will fail an absence check that its author expected to pass. ## The empty-result rule That last bullet decides most interview answers. On every poll Playwright resolves the locator, and when the result is empty there is no element to read a property from — so each matcher needs a defined answer for the empty case. The visibility and attachment family has one, and it is the intuitive one: nothing is hidden, and nothing is not visible. | Assertion | Element missing | Element attached but hidden | |---|---|---| | `toBeHidden()` | passes | passes | | `not.toBeVisible()` | passes | passes | | `not.toBeAttached()` | passes | fails after retrying | | `toHaveCount(0)` | passes | fails after retrying | | `not.toHaveText('Pending')` | fails after retrying | compares the text | The first two rows are identical in every column. `toBeHidden()` and `not.toBeVisible()` are interchangeable in outcome: there is no page state where one passes and the other fails. ## What actually differs 1. **Direction of the report.** `toBeHidden()` fails with an expected/received pair phrased around hidden versus visible, while the negated form phrases the expectation as *not visible*. In a long CI log the positive phrasing is quicker to scan. 2. **Readability under review.** `not.toBeVisible()` is a double negative in a test that is already about something disappearing, and double negatives are exactly what a reviewer skims past. 3. **Greppability.** A suite that mixes both spellings — plus the third one, `toBeVisible({ visible: false })` — cannot be audited for absence checks with a single search. None of those differences are behavioural. Pick one spelling per suite and hold the line in review; `toBeHidden()` is the usual choice because it reads as a statement rather than a denial. ## The trap both of them share Because an empty result passes, an absence check says nothing about history. On a bank statement page, `await expect(page.getByTestId('export-spinner')).toBeHidden()` is satisfied by all of these: - a spinner that appeared while the CSV was generated and then finished — the intended case - a spinner that never appeared because the export button was never wired to anything - a request that returned 500, so the app rendered an error shell instead of the statement - a typo in the test id, which matches nothing on any page in the application Three of those four are defects, and the assertion is green for all four. This is the failure mode the family is known for: the check passes in about a millisecond on a page that rendered nothing at all. ## Closing the gap The fix is ordering, not matcher choice. Put a **positive anchor** in front of every absence check — an assertion that can only hold if the page reached the state you are about to watch it leave: - assert the container rendered: `await expect(page.getByRole('table', { name: 'Transactions' })).toBeVisible()` - assert the thing you expect to vanish first appeared: `await expect(spinner).toBeVisible()` before `await expect(spinner).toBeHidden()` - or replace the negation with a number: `await expect(rows).toHaveCount(12)` fails on an unrendered page, because zero is not twelve And if the requirement is *removal from the DOM* rather than *not shown to the user*, neither of these two matchers is the right tool. `not.toBeAttached()` is, because it keeps failing while the node is still mounted, however invisible it happens to be.

  • How would you assert that the balance widget is present in the DOM but not shown to the user?
    Two assertions on the same locator: `await expect(balance).toBeAttached()` and `await expect(balance).toBeHidden()`. The first proves the node exists, the second proves it is not rendered. Collapsing them into a single negation loses the distinction, because a missing node satisfies `toBeHidden()` on its own.
  • Why is toBeHidden() usually the better spelling to standardise on?
    It states intent positively and fails with a message phrased in the same direction, which is faster to scan in a CI log. Double negatives are easy to misread in review, and a suite that mixes both spellings cannot be audited for absence checks with one search.

saying these in an interview costs you the question

  • Claims not.toBeVisible fails when the element is missing entirely.
  • Thinks toBeHidden only checks the CSS visibility property.
  • Believes toBeHidden proves the element existed and then went away.
  • Treats an element scrolled out of the viewport as hidden.
  • Assumes the two matchers use different waiting mechanisms.
open as a page

In Playwright, what happens if you forget to await expect(locator).toBeVisible()?

level: juniorimportance: must knowfreq 84%

basics

~20 s

Playwright's web-first matchers return a promise. Without await, the test body runs on and the test finishes before the matcher decides anything, so the assertion can never fail it — the error is dropped or blamed on a later test.

open as a page

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

level: middleimportance: must knowfreq 63%

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.

open as a page

How does Playwright's expect(locator).toHaveText() retry until the text matches?

level: middleimportance: must knowfreq 70%

basics

~20 s

Each attempt re-runs the locator query from scratch, reads the element's text, normalizes whitespace and compares it. Playwright repeats until the condition holds or the expect timeout (5 seconds by default in 1.63) expires, then reports the last value seen.

open as a page

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

level: middleimportance: should knowfreq 37%

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.

open as a page

In Playwright, when do you use expect(locator).toHaveText() versus toContainText()?

level: middleimportance: should knowfreq 58%

basics

~20 s

Both retry and normalize whitespace, but toHaveText requires the element's entire text to match while toContainText only requires a substring. Use the exact form on tightly-scoped elements and the containment form when surrounding text is volatile.

open as a page

In Playwright, how do you check that a deleted transaction row is really gone and not merely never rendered?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Bracket the delete with positive assertions. Assert the table and the target row are visible and the row count is known, delete, then assert the count dropped by one and the row is no longer attached.

open as a page

Why does asserting Playwright's locator.textContent() with expect(value).toBe() flake where toHaveText() does not?

level: seniorimportance: should knowfreq 52%

basics

~20 s

Reading with textContent() takes one snapshot of the text, and comparing it with a generic matcher happens once. Nothing retries, so a value that arrives moments later fails. toHaveText re-reads the element until it matches or the timeout expires.

open as a page

When is asserting with Playwright's toHaveCSS or toHaveClass worth the coupling it creates?

level: principalimportance: nice to knowfreq 34%

basics

~20 s

Only when the styling itself is the requirement, such as an overdraft that must be visually marked, or when the class is a documented state contract. Otherwise assert user-visible meaning or a stable state attribute instead.

open as a page