QA Interview Hub · Knowledge base · Quizzes
Playwright
Auto-waiting and web-first assertions
Web-first assertions
Web-first assertions are expect with a locator: toBeVisible, toHaveText, toHaveValue, toHaveCount, toHaveURL and so on. They check a condition in a loop until it becomes true or the timeout expires, which is 5 seconds by default and is configured through expect.timeout. They are written with await before expect: await expect(locator).toHaveText('Done').
A bad practice is expect(await locator.textContent()).toBe('Done'): here the value is taken once, and two strings are compared without repeats. If the page hasn't updated yet, the test fails, and there is a temptation to add waitForTimeout. Likewise, instead of isVisible() with a check of the boolean value, use toBeVisible().
Additionally: expect.soft doesn't stop the test on a failure but collects all errors. expect.poll and expect(...).toPass() give retries for your own logic, for example for checks through an API. A negation check is written with not: await expect(locator).not.toBeVisible().
Actionability: what exactly is checked before an action
The set of checks depends on the action. All actions wait for the element to be attached to the DOM, and beyond that the difference is as follows:
| Action | What Playwright waits for |
|---|
| click, check, uncheck, setChecked | visible, stable, receives events, enabled |
| hover, dragTo | visible, stable, receives events |
| fill, clear | visible, enabled, editable |
| selectOption | visible, enabled |
| screenshot | visible, stable |
"Stable" means the element isn't animating for two frames in a row. "Receives events" means that at the click point there is exactly this element, not an overlay or a spinner. If an element is covered, Playwright waits and writes in the logs which element intercepts the click.
Two useful options. force: true turns off some of the checks: it is a workaround that may hide a real bug where a user also couldn't click. trial: true performs all the checks but doesn't do the action itself: this way you can wait for an element to be ready, for example before a hover menu.
Navigation has its own waits. page.goto waits for the load event by default. The option waitUntil: 'networkidle' is officially not recommended: on pages with polling or analytics it is unstable. After an action that changes the URL, better use await expect(page).toHaveURL(/orders/) or page.waitForURL.
In an interview: Before a click Playwright waits for the element to be visible, stable, enabled and not covered, and before fill it waits for the field to be editable. I use force: true very rarely, because it may hide a real bug with an overlay. Instead of networkidle I wait for a concrete result: a URL, an element or an API response.
expect.poll and toPass: retries for your own logic
Web-first assertions retry only when a locator or a page is passed to expect. When you need to wait for something else, such as a status in the DB, an API response or a file, you use expect.poll or toPass.
expect.poll calls a function repeatedly and applies a matcher to the result:
await expect.poll(async () => {
const res = await request.get(`/api/orders/${id}`);
return (await res.json()).status;
}, { message: 'the order must become PAID', timeout: 30_000 }).toBe('PAID');
By default it takes the expect timeout (5 seconds), and the intervals grow: 100, 250, 500, 1000 ms. They can be set through intervals.
toPass retries a whole block of code with several actions and assertions until the block passes without errors:
await expect(async () => {
await page.getByRole('button', { name: 'Refresh' }).click();
await expect(page.getByText('Synced')).toBeVisible({ timeout: 1000 });
}).toPass({ timeout: 20_000, intervals: [1000, 2000] });
A tricky point: in toPass the default timeout is 0, meaning it doesn't take expect.timeout and retries until the test time runs out. So I set the timeout explicitly. Inside I also give the inner assertions short timeouts, otherwise one iteration would take 5 seconds.
In an interview: When I wait not for the UI but, for example, for asynchronous payment processing on the backend, I use expect.poll with a function that polls the API. If I need to repeat a whole sequence of actions, I take toPass and always give it a timeout, because by default it is limited only by the test timeout.
Soft assertions, messages and custom matchers
expect.soft records an error but doesn't stop the test. The test will still be red, and the report will have all the failures together. This is handy for checking a form with many fields or a profile page: in one run you see all the discrepancies.
await expect.soft(page.getByLabel('Name')).toHaveValue('Rostyslav');
await expect.soft(page.getByLabel('Email')).toHaveValue('r@test.com');
await expect.soft(page.getByLabel('Phone')).toHaveValue('+380501112233');
expect(test.info().errors).toHaveLength(0);
await page.getByRole('button', { name: 'Save' }).click();
The last line before the action is a practical trick: if the soft assertions have already failed, there is no point going further.
Soft shouldn't be put on preconditions. If a page didn't open, all the following checks will fail in a cascade and make the report noisy.
As the second argument of expect you can pass your own message: expect(locator, 'the pay button must be enabled').toBeEnabled(). It appears in the report and trace, and a failure reads without opening the code.
expect.configure creates a configured instance: const slowExpect = expect.configure({ timeout: 15_000 }) or { soft: true }. And expect.extend lets you write custom matchers, for example toHaveValidIban, which return pass and message.
In an interview: I use expect.soft for independent checks of one page, to see all the discrepancies in one run, but not for preconditions. To important assertions I add my own message, so that a failure in the report is clear without the code.