Dynamic content & waits
Each exercise needs a different waiting strategy. If you reach for a fixed sleep (waitForTimeout), your test will either be slow or flaky. Wait for the state you need instead.
1. Delayed element
BeginnerClick Start. A spinner shows for a random 2–5 seconds, then a heading appears.
Hello World!
Expected: the spinner appears, then the finish heading becomes visible with the greeting and the spinner is gone. Your test must pass whether the load takes 2 or 5 seconds.
Show me how
import { test, expect } from '@playwright/test'; test('waits for the delayed element', async ({ page }) => { await page.goto('/dynamic.html'); await page.getByTestId('start-loading').click(); await expect(page.getByTestId('loading')).toBeVisible(); // expect() retries for 5 s by default, but this load can take up to 5 s // plus rendering. Raise the timeout for this one assertion only. const finish = page.getByTestId('finish'); await expect(finish).toBeVisible({ timeout: 10_000 }); await expect(finish).toHaveText('Hello World!'); await expect(page.getByTestId('loading')).toBeHidden(); });2. Hidden vs removed
BeginnerOne paragraph is hidden with CSS (still in the page), the other is removed from the DOM completely.
I am sometimes removed.
Expected: after toggling, hidden-target is not visible but still exists (count 1), while removed-target doesn't exist at all (count 0). Toggling again brings both back.
Show me how
import { test, expect } from '@playwright/test'; test('tells hidden and removed elements apart', async ({ page }) => { await page.goto('/dynamic.html'); const hidden = page.getByTestId('hidden-target'); const removed = page.getByTestId('removed-target'); await page.getByTestId('toggle-hidden').click(); await page.getByTestId('toggle-removed').click(); // toBeHidden() passes for BOTH cases, so it can't tell them apart... await expect(hidden).toBeHidden(); await expect(removed).toBeHidden(); // ...but counting the matches can. await expect(hidden).toHaveCount(1); await expect(removed).toHaveCount(0); await page.getByTestId('toggle-hidden').click(); await page.getByTestId('toggle-removed').click(); await expect(hidden).toBeVisible(); await expect(removed).toBeVisible(); });3. Enable / disable
BeginnerThe input starts disabled. Clicking the button "calls a server" for 2 seconds, then enables it.
Expected: the input is disabled at first, enabled after the click (message It's enabled!), and you can type into it.
Show me how
import { test, expect } from '@playwright/test'; test('types into the input once it is enabled', async ({ page }) => { await page.goto('/dynamic.html'); const input = page.getByTestId('async-input'); await expect(input).toBeDisabled(); await page.getByTestId('toggle-enable').click(); // fill() would also wait for the input to become editable on its own, // but asserting first makes the failure message much clearer. await expect(input).toBeEnabled(); await expect(page.getByTestId('enable-message')).toHaveText("It's enabled!"); await input.fill('Now I can type'); await expect(input).toHaveValue('Now I can type'); });4. Add / remove elements
BeginnerEach click adds a Delete button; clicking one removes it.
Expected: three clicks create three buttons (added-1 to added-3); deleting the second leaves two, and added-2 no longer exists.
Show me how
import { test, expect } from '@playwright/test'; test('adds and removes elements', async ({ page }) => { await page.goto('/dynamic.html'); const add = page.getByTestId('add-element'); // Scope the locator to the container so nothing else on the page matches. const buttons = page.getByTestId('elements').getByRole('button', { name: 'Delete' }); for (let i = 0; i < 3; i++) await add.click(); await expect(buttons).toHaveCount(3); await page.getByTestId('added-2').click(); await expect(buttons).toHaveCount(2); await expect(page.getByTestId('added-2')).toHaveCount(0); });5. Dynamic IDs
BeginnerThis button gets a new random id on every page load, so a recorded #btn-… selector breaks next time.
Expected: your test clicks the button reliably on any load, and the message confirms the click with the current id. Reloading gives a different id.
Show me how
import { test, expect } from '@playwright/test'; test('clicks a button whose id keeps changing', async ({ page }) => { await page.goto('/dynamic.html'); // Locate by role + accessible name (what users see), or by test id. // Never by the generated id. const button = page.getByRole('button', { name: 'Click me' }); const firstId = await button.getAttribute('id'); await button.click(); await expect(page.getByTestId('dynamic-id-result')).toHaveText(`Clicked button with id "${firstId}".`); // Prove the id really is unstable. await page.reload(); expect(await button.getAttribute('id')).not.toBe(firstId); });6. Progress bar
AdvancedThe bar gains 1% every 100 ms. Stop it at exactly 75%.
Expected: the result reads Stopped at 75%. Perfect! every time you run the test, not just sometimes.
Show me how
import { test, expect } from '@playwright/test'; test('stops the progress bar at exactly 75%', async ({ page }) => { // Polling the bar and clicking "fast enough" is a race you will lose // on a slow machine. Instead, freeze time and move it ourselves. await page.clock.install(); await page.goto('/dynamic.html'); // pauseAt() jumps a little ahead (it can't go back in time, so leave a // margin for the page load) and stops the clock: no timers fire until we say so. await page.clock.pauseAt(new Date(Date.now() + 10_000)); await page.getByTestId('progress-start').click(); // 75 ticks of 100 ms = 75%. await page.clock.runFor(7_500); await expect(page.getByTestId('progress')).toHaveAttribute('aria-valuenow', '75'); await page.getByTestId('progress-stop').click(); await expect(page.getByTestId('progress-result')).toHaveText('Stopped at 75%. Perfect!'); });7. Text changes
BeginnerThe job status goes Pending → Processing → Done over about 4 seconds.
Status: Idle
Expected: your test sees all three statuses in order and finishes as soon as the status is Done.
Show me how
import { test, expect } from '@playwright/test'; test('follows the job through every status', async ({ page }) => { await page.goto('/dynamic.html'); const status = page.getByTestId('job-status'); await page.getByTestId('start-job').click(); // Each assertion retries until its text shows up, so the test // moves on the instant each state is reached. await expect(status).toHaveText('Pending'); await expect(status).toHaveText('Processing'); await expect(status).toHaveText('Done'); await expect(page.getByTestId('start-job')).toBeEnabled(); });8. Covered button
IntermediateFor 3 seconds after the page loads, an overlay sits on top of the button and swallows clicks.
Expected: the click lands on the real button (message Button clicked!), not on the overlay, without forcing it.
Show me how
import { test, expect } from '@playwright/test'; test('clicks the button once the overlay is gone', async ({ page }) => { await page.goto('/dynamic.html'); // Option A: just click. Before clicking, Playwright checks the element // would actually receive the click, and retries while the overlay // is on top of it. await page.getByTestId('covered-button').click(); // Option B (more explicit): wait for the overlay to go first. // await expect(page.getByTestId('overlay')).toBeHidden(); // Don't use click({ force: true }): it skips those checks and clicks // the overlay, so the test "passes" without doing what a user does. await expect(page.getByTestId('covered-result')).toHaveText('Button clicked!'); });9. Load more
IntermediateEach click loads 5 more items after about a second, up to 20 in total.
Expected: after four batches there are exactly 20 items, and the button is disabled with the text No more items.