Playwright interview question
What are web-first assertions, and why is expect(await locator.textContent()).toBe() an anti-pattern?
Short answer
Web-first assertions such as expect(locator).toHaveText() take a locator and retry until the condition is met or the expect timeout (5 seconds by default) expires. Reading a value first with await locator.textContent() takes one snapshot and asserts immediately, so it fails on content that is still loading.
Answer
Web-first assertions are the async matchers on expect(locator) (and on expect(page)), such as toBeVisible, toHaveText and toHaveURL. They re-resolve the locator and re-check the condition repeatedly until it passes or the expect timeout expires (5 seconds by default). Reading a value yourself and then asserting on it uses a generic matcher on a single snapshot, so if the UI has not finished updating, the test fails even though it would have been correct a moment later.
The anti-pattern
// Snapshot: reads the text once, right now
expect(await page.getByTestId('cart-count').textContent()).toBe('3');
// Snapshot: isVisible() returns immediately and does not wait
expect(await page.getByText('Saved').isVisible()).toBe(true);
After clicking “Add to cart”, the count may update after an API call. The first assertion races that update; on a fast laptop it passes, in CI it fails sometimes. That is a textbook flaky test.
The web-first version
await expect(page.getByTestId('cart-count')).toHaveText('3');
await expect(page.getByText('Saved')).toBeVisible();
These retry until the text is 3 and the message is visible, up to the timeout. Note the await: web-first assertions are asynchronous, and forgetting await means the assertion is never really checked before the test moves on. The @typescript-eslint/no-floating-promises lint rule catches this.
Common matchers
await expect(page).toHaveURL(/\/orders\/\d+$/);
await expect(page).toHaveTitle('Checkout');
await expect(locator).toHaveText('Paid'); // full text (normalised whitespace); regex allowed
await expect(locator).toContainText('Paid');
await expect(locator).toHaveValue('qa@example.com');
await expect(locator).toBeEnabled();
await expect(locator).toBeChecked();
await expect(locator).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByRole('listitem')).toHaveText(['Apples', 'Bananas', 'Cherries']);
toHaveText with an array checks the whole list in order, in one retrying assertion, which is much more reliable than looping over items.
Negation waits too
await expect(page.getByRole('progressbar')).not.toBeVisible(); // or toBeHidden()
This waits for the spinner to disappear, rather than checking once.
Timeouts
// playwright.config.ts
export default defineConfig({ expect: { timeout: 10_000 } });
// per assertion
await expect(page.getByText('Report ready')).toBeVisible({ timeout: 30_000 });
Raise the timeout for genuinely slow operations, not to paper over a wrong expectation.
Soft assertions
expect.soft(...) records a failure but lets the test continue, useful for checking several independent fields on one page and seeing all mismatches in one run. The test is still marked failed at the end.
Values that are not in the DOM
Generic matchers like toBe do not retry. If you need to wait for an API value or any computed result, use expect.poll:
await expect.poll(async () => {
const res = await request.get('/api/orders/42');
return (await res.json()).status;
}, { timeout: 15_000 }).toBe('SHIPPED');
or await expect(async () => { ... }).toPass() for a block of several assertions that should eventually all pass.
What interviewers look for
- The retrying behaviour and the default timeout.
- A clear explanation of why snapshot reads cause flakiness, with
isVisible()as an example. - Fluency with the main matchers, including array
toHaveTextand negation. - Knowing
expect.pollandtoPassfor non-DOM conditions.
A strong answer covers
- Web-first assertions take a locator and retry until timeout
- Snapshot reads like textContent/isVisible do not wait
- Names common matchers: toBeVisible, toHaveText, toHaveURL, toHaveCount
- Knows the default 5s expect timeout and how to configure it
- Mentions soft assertions, negation or expect.poll/toPass for non-DOM values