In Playwright, what happens if you forget to await expect(locator).toBeVisible()?
answer
- The matcher hands back a promise
- The test body does not pause
- Green run, verdict never reached
- Failure lands after the test ended
- A lint rule catches floating assertions
basics
~20 sPlaywright'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.
solid answer
~40 s`expect(locator).toBeVisible()` is asynchronous: it starts a retry loop and returns a promise that settles when the condition holds or the expect timeout expires. Drop the `await` and the test body continues while the loop is still running, so the test usually ends and reports green before the matcher has reached a verdict. If the assertion later fails, the rejection arrives after the test is over — Playwright may attribute it to a following test, or the page and context are already closed and the error is lost. The rule is that every `expect(locator)…` and every locator action is awaited; only the generic matchers over plain values (`expect(count).toBe(24)`) are synchronous. Turn on `@typescript-eslint/no-floating-promises`, or `playwright/missing-playwright-await` from `eslint-plugin-playwright`, so a linter catches it instead of a mysteriously green run.
code
typescript · 9 linesimport { test, expect } from '@playwright/test';
test('balance settles after a refresh', async ({ page }) => {
await page.goto('/statements/2026-08');
await page.getByRole('button', { name: 'Refresh' }).click();
// The await is what lets this assertion fail the test.
await expect(page.getByTestId('balance')).toHaveText('£1,240.00');
});go deeper
Remember the shape: any expect whose subject is a page or a locator is awaited. If you see expect(page… or expect(locator… without await, that is the bug.
Be able to explain the mechanics: the matcher starts a retry loop, returns a promise, and the test function returning is what ends the test regardless of that loop.
Show how you keep it out of the suite — type-aware lint rules in CI, suspicion of tests that finish far too fast, and reading a trace to confirm an assertion actually ran.
Own the policy: this class of defect makes a suite quietly stop testing, so treat lint enforcement and a review habit around helper functions that return promises as a suite-health investment, not style.
## Two kinds of expect that look alike Playwright ships one `expect` that covers two very different mechanisms. `expect(value).toBe(...)` and its friends are **generic matchers**: they compare a value you already hold, decide on the spot, and throw synchronously. `expect(locator).toBeVisible()`, `.toHaveText()`, `.toHaveValue()`, `.toHaveCount()` and the rest are **web-first matchers**: their subject is a locator, which is a lazy description of an element rather than the element itself. A web-first matcher starts a retry loop that re-queries the page, checks the condition, and keeps going until it holds or the expect timeout elapses (5 seconds by default in Playwright 1.63). A loop that runs over time cannot report its verdict by throwing immediately, so the matcher reports it through a **promise** instead. ## What a missing await actually does Take the statement page: the balance widget fills in after a background fetch, and the test writes `expect(page.getByTestId('balance')).toHaveText('£1,240.00')` with no `await`. 1. The matcher starts its retry loop and immediately returns a pending promise. 2. Nothing awaits that promise, so the test body runs straight on to the next statement. 3. The test function returns. Playwright records the test as **passed** — no assertion failed, because none of them finished. 4. The loop is still polling a page that is being torn down; whatever it concludes arrives too late to change the result. The failure does not simply disappear, it relocates. Depending on timing you get an error attributed to a **later** test in the same worker, an unhandled rejection in the output with no obvious owner, or silence because the context closed and the loop died with it. All three are worse than a plain failure, because none of them points at the line that is actually wrong. ## The rule, in one table | Expression | What it returns | Await it? | |---|---|---| | `expect(locator).toHaveText('£1,240.00')` | promise | **always** | | `expect(page).toHaveURL(/statements/)` | promise | **always** | | `locator.click()`, `locator.fill('42')` | promise | **always** | | `expect(rowCount).toBe(24)` over a plain value | nothing; throws on failure | no | The shortcut that survives review: if the argument to `expect` is a `page` or a `locator`, the line begins with `await`. ## Why this bug outlives code review - The run is **green**, so nobody opens it. A missing `await` silently removes a check; it never produces a red run to chase. - The diff looks correct — every character of the assertion is right except the five that are absent. - The test gets *faster*, which reads as a good sign rather than a warning. - The eventual failure surfaces in an unrelated test, so the investigation starts in the wrong file. ## Near-miss forms of the same mistake - `await page.getByTestId('balance')` — awaiting a locator is legal and does nothing; a locator is not a promise, so `await` just hands it back. The assertion still needs its own `await`. - `await expect(balance).toBeVisible;` — the parentheses are missing, so this awaits a function reference and never runs a check. - Wrapping assertions in a helper that forgets to return its promise: `function checkBalance(page) { expect(...).toHaveText('...'); }` swallows the await at the call site even when the caller awaits. ## Catching it mechanically - Enable `@typescript-eslint/no-floating-promises` (needs type-aware linting) so any un-awaited promise is a lint error. - Add `eslint-plugin-playwright` and its `playwright/missing-playwright-await` rule, which targets this exact shape. - Treat a suspiciously fast test as a smell: a test that asserts on data loaded over the network and finishes in a few milliseconds is usually not asserting anything. - When you touch an assertion, check the trace: a web-first matcher that ran appears in the call log with its retries, and one that never settled leaves a gap where the check should be. The cost of the habit is one keyword. The cost of missing it is a test that has been reporting green about a page it never looked at.
- Which expect calls in a Playwright test legitimately have no await in front of them?Only the generic matchers over values you already hold — `expect(rowCount).toBe(24)`, `expect(names).toEqual([...])`. They compare once and throw synchronously. Anything whose subject is a `locator` or a `page` runs a retry loop and returns a promise, so it must be awaited.
- Why does a missing await often surface as a failure in a different test than the one that is wrong?The abandoned retry loop keeps running after the test function returns. If it rejects while the worker has already moved on, Playwright reports the error against whatever test is running at that moment — or as an unhandled rejection with no owner. The stack points at the original line, but the failing test name does not.
saying these in an interview costs you the question
- Thinks web-first matchers are synchronous like plain unit-test matchers
- Says a missing await only makes the test slower
- Assumes the run turns red as soon as the promise rejects
- Awaits the locator instead of the expect call
- Believes code review reliably spots a missing await
- Adds a sleep instead of awaiting the assertion