RizTech Academy logo
RizTech Academy
Web Automation with PlaywrightLesson 3 of 635 min

Actions and web-first assertions, and why you never sleep()

This is the lesson that decides whether your suite is trustworthy. Two ideas — acting on elements and asserting with web-first assertions — and one rule that follows from both: you never write sleep(). Almost all UI flakiness comes from tests that check too early, and Playwright's design exists to stop that. Get this right and your tests wait exactly as long as needed, no more, and never race. This lesson is actions, web-first assertions, and the end of the fixed wait.

Actions, and the auto-waiting that comes free

Actions are what a user does: click, fill, select, check, press. In Playwright:

await page.getByLabel('Amount (₹)').fill('750');
await page.getByLabel('Type').selectOption('payment');
await page.getByRole('button', { name: 'Create transaction' }).click();

Every action auto-waits before it runs. Before clicking, Playwright checks the element is attached, visible, stable (not animating), enabled, and able to receive the event — and waits (up to a timeout) until it is. So you do not write "wait for the button to appear, then click"; the click waits for the button itself. This single behaviour removes the most common reason older tests were flaky: acting before the app was ready.

Web-first assertions: assertions that wait

The other half is assertions. A naive assertion checks once:

// fragile: reads the text at this instant
const text = await page.getByTestId('page-indicator').textContent();
expect(text).toBe('Page 2 of 6');

If the app updates that text a moment later (an async re-render), this fails — a race. Playwright's web-first assertions fix it by retrying until the condition holds or the timeout expires:

// robust: retries until it matches
await expect(page.getByTestId('page-indicator')).toContainText('Page 2');

await expect(locator).toContainText(...), .toBeVisible(), .toHaveCount(11), .toHaveText([...]), .toBeEnabled() — all poll the live page until they pass. This is why AgentPay can click "Next" and then immediately assert the page changed: the assertion waits for the re-render. Use web-first assertions (expect(locator).…) for anything on the page, and reserve plain expect(value) for values you have already computed.

Why you never sleep()

The cardinal sin of UI automation is the fixed wait:

await page.getByRole('button', { name: 'Next' }).click();
await page.waitForTimeout(2000); // ❌ never do this
const ids = await page.locator('tbody tr td:first-child').allInnerTexts();

It is wrong in both directions (as the flaky-tests lesson said): 2 seconds is too short on a slow run — the data has not loaded, the read is stale, the test fails randomly (flaky); and 2 seconds is too long on a fast run — every test wastes it, and a suite of hundreds crawls. A fixed sleep guesses at timing, and guessing is exactly what causes flakiness.

The fix is to wait for the condition, not the clock. Auto-waiting actions and web-first assertions do this for you: they wait precisely until the thing is ready, then proceed instantly. When you need to wait for something they do not cover, wait for that specific thing:

// AgentPay: after clicking Next, wait for the rows to actually change before reading
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByTestId('page-indicator')).toContainText('Page 2');
await expect(page.locator('tbody tr td:first-child').first()).not.toHaveText(firstPageIds[0]);
const secondPageIds = await page.locator('tbody tr td:first-child').allInnerTexts();

That third line is the key: it waits until the first row's id is different from page one's — a definite signal that the new data has rendered — and only then reads. No sleep, no race. This exact pattern is why AgentPay's pagination and sort tests are reliable: they were flaky when they read eagerly, and became solid when they waited on a real condition. (waitForTimeout exists; it is for debugging only, never in a real test.)

expect.poll for anything else

Sometimes the condition is computed, not a single locator state — for example "the visible amounts are in ascending order". expect.poll retries an arbitrary function until it returns the expected value:

// AgentPay: wait until the amounts column is sorted ascending
await expect
  .poll(async () => {
    const amounts = (await page.locator('tbody tr td:nth-child(3)').allInnerTexts()).map(Number);
    return amounts.every((v, i, arr) => i === 0 || arr[i - 1] <= v);
  })
  .toBe(true);

This waits for the sort to take effect without any sleep, then asserts. It is the general tool for "wait until my own check passes" — the same wait-for-the-condition principle, applied to logic you write.

Check your work

Actions auto-wait. click/fill/selectOption/check/press each wait for the element to be attached, visible, stable, enabled and actionable before running — so you never "wait then act"; the action waits for its own target. This removes the top cause of flakiness.

Web-first assertions retry. await expect(locator).toBeVisible()/.toContainText()/.toHaveCount()/ .toHaveText() poll the live page until they pass or time out — so an assertion right after an async update does not race. Use them for anything on the page; plain expect(value) only for already-computed values.

Never sleep(). A fixed waitForTimeout is too short (flaky) and too long (slow) at once — guessing at timing is what causes flakiness. Wait for the condition: auto-waiting + web-first assertions do it, and for uncovered cases wait on a specific signal (AgentPay waits for the first row id to change before reading page 2).

expect.poll retries an arbitrary function until it returns the expected value — for computed conditions like "amounts are sorted ascending" (AgentPay's sort test). waitForTimeout is debugging-only.

Practice

  1. Write the AgentPay "create transaction" flow using auto-waiting actions (fill, select, click) with no manual waits.
  2. Turn a once-off textContent() check into a web-first expect(locator).toContainText(...) and explain what changes.
  3. Take a test with waitForTimeout(2000) and rewrite it to wait for a real condition; say why the sleep was both flaky and slow.
  4. Use the "wait until the first row changes" pattern to make a pagination test reliable.
  5. Write an expect.poll that waits until a filtered table shows only "failed" rows.
  6. Explain, to someone who insists "just add a bigger sleep", why that makes the suite worse, not better.

Official documentation

Next: page objects and keeping tests maintainable.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship