11.6 — Auto-waiting & web-first assertions
This is the lesson 7.8 promised and 11.1 credited with ending an era: web-first assertions — expect(locator) checks that RETRY until the page catches up, passing the instant truth arrives. Dynamic content — spinners, late lists, toasts — stops being your problem and becomes the assertion’s.
First, honor the old enemy properly: understand exactly why sleep() can never win.
// the OLD world (other tools, sadder days):
// click(); sleep(5000); read(); ← guess & pray
// Playwright:
await page.getByRole("button", { name: "Search" })
.click();
await expect(page.getByText("12 results"))
.toBeVisible();
// ↑ polls: check · wait · check · …
// passes the INSTANT it's true (or times out)
await expect(page.getByRole("listitem")).toHaveCount(12);
await expect(page.getByLabel("Email"))
.toHaveValue("ada@shop.com");
await expect(page.getByText("Saved!")).toBeHidden();The reality every browser test lives in: pages are DYNAMIC. Click Search and the answer does not exist yet — a spinner shows, a request travels (6.7), the response renders (7.8). “12 results” appears LATER, after a delay nobody can know in advance: 200ms on a good day, 3 seconds on a bad one.
The deeper story, with the real names for things — this part is what turns “I saw it” into “I can explain it.”
The polling loop’s internals: checks run on the browser side where possible, with gentle intervals (effectively ~100ms-ish pacing), each iteration re-resolving the locator — so even if the framework replaces the DOM node entirely (React re-renders constantly), the assertion follows the description, not a dead reference. 11.4’s design decision pays its rent here, permanently.
toHaveText has precise semantics worth knowing: it normalizes whitespace, matches the FULL text (use toContainText for substrings), and accepts arrays for lists (toHaveText(["Red", "Blue", "Green"]) asserts all three items in order — a whole list shape in one retrying assertion).
For truths that live outside the DOM — a value in your own code, an API’s answer — there’s await expect.poll(() => fn()) and expect(async …).toPass(): the same retry engine pointed at arbitrary functions. Your exercise’s pollUntil is literally this, hand-built.
Job note: you can now READ flakiness statistics: teams migrating from sleep-based suites to web-first assertions routinely report both faster runs (no padded sleeps) AND order-of-magnitude fewer flakes — the same change fixes both directions of sleep’s failure. When 11.15’s flakiness clinic lists “race conditions” as cause #1, the cure column will just say: this lesson.
⌨️ build the polling assertion
The starter fakes a dynamic page on a hand-cranked clock (deterministic — no real waiting). Build the web-first assertion: poll until truth or budget, and report WHICH.
requirements:
- Keep the starter:
tick()advances the fake clock 100ms;getResults()answers""until 300ms have passed, then"12 results"— a page whose truth arrives later. - Write
pollUntil(get, expected, maxChecks): up to maxChecks times — checkget() === expected; on truth, return which check number succeeded; otherwisetick()and try again. Exhausted → return-1. - Run it: expect
"12 results"with 10 checks → printpassed on check 4. Then expect"99 results"with 5 checks → printtimed out after 5 checks.
when you press RUN, the console must show exactly:
✏️ Quick check 1
sleep(2000) before reading a page that takes 3s today — does the test pass, fail, or waste time?
✏️ Quick check 2
expect(locator).toBeVisible() — how does it behave between the click and the element appearing?
✏️ Quick check 3
Which family retries — expect(125).toBe(125) or expect(locator).toHaveText("Ada")?
🗣️ Now teach it back
Explain the flaky-test killer to a friend: why dynamic pages break sleep() in BOTH directions, how a polling assertion works (freshness included), what bounds its patience, and the one honest exception to “never wait by hand.”
Write it as if your friend is sitting next to you. Saved to your journal — future-you will use these notes to teach others.