These are my preferred solutions. Yours may differ—if the locator is stable, accessible, and readable, it’s correct. The notes after each solution explain why this particular approach, not just what.
Every snippet assumes the test has already called await page.goto('/playground').
Warm-up: role basics
Challenge 1: “Add to shelf” button
await expect(page.getByRole('button', { name: 'Add to shelf' })).toBeVisible();The simplest case. getByRole with a name match. This is the locator you should reach for first, every time.
Challenge 2: “Cancel” button
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible();Same pattern. The button’s text content is its accessible name.
Challenge 3: Disabled “Out of stock” button
const outOfStock = page.getByRole('button', { name: 'Out of stock' });
await expect(outOfStock).toBeDisabled();toBeDisabled() is an auto-retrying assertion. You don’t need to check getAttribute('disabled')—Playwright understands the semantic.
Challenge 4: Search input by label
await expect(page.getByLabel('Search')).toBeVisible();getByLabel matches against the <label> element associated with the input. This is the second tier of the hierarchy, and for form inputs with visible labels, it’s often the most natural choice.
Intermediate: disambiguation and chaining
Challenge 5: First “Delete” button
await expect(page.getByRole('button', { name: 'Delete' }).first()).toBeVisible();Two elements match getByRole('button', { name: 'Delete' }). Calling .first() narrows to the first one in DOM order. .nth(0) does the same thing. In a real test, you’d probably scope by parent instead—but this exercise is about knowing that .first() exists.
Challenge 6: “Remove” in the third list item
const readingList = page.getByRole('list', { name: 'Reading list' });
const thirdItem = readingList.getByRole('listitem').nth(2);
await expect(thirdItem.getByRole('button', { name: 'Remove' })).toBeVisible();Three-level chain: list → third item → button. Each step narrows the scope. This is the alternative to a compound CSS selector like .reading-list li:nth-child(3) button.remove—and it reads in the same order a person thinks.
Challenge 7: “Rate this book” inside the Piranesi article
const article = page.getByRole('article', { name: /Piranesi/ });
await expect(article.getByRole('button', { name: 'Rate this book' })).toBeVisible();The <article> has aria-label="Piranesi by Susanna Clarke". A regex match on /Piranesi/ is enough to uniquely identify it. Then scope inside to find the button.
Challenge 8: Author input hint text
await expect(page.getByLabel('Author')).toBeVisible();
await expect(page.getByText('Last name, first name')).toBeVisible();The hint is a separate element—not part of the input’s label. You can locate the input by label and the hint by text independently.
Text and content
Challenge 9: Paragraph mentioning “42 days”
await expect(page.getByText('This book has been on your shelf for 42 days.')).toBeVisible();Exact text match. When the text is unique on the page, getByText with the full string is the cleanest option.
Challenge 10: “3 of 12 books finished”
await expect(page.getByText('3 of 12 books finished')).toBeVisible();Same approach. Unique text, full match.
Challenge 11: The right “shelf” paragraph
await expect(page.getByText('You have 4 books on your shelf right now.')).toBeVisible();The other paragraph containing “shelf” says “Add a book to your shelf to get started.” Using the full sentence avoids the ambiguity entirely. A regex like /4 books on your shelf/ also works—the point is to be specific enough that only one element matches.
Tables and lists
Challenge 12: Count data rows in the ratings table
const table = page.getByRole('table', { name: 'Book ratings' });
const dataRows = table.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await expect(dataRows).toHaveCount(3);getByRole('row') returns all rows, including the header. Filtering out rows that contain columnheader cells gives you the data rows. An alternative is table.locator('tbody tr'), but that drops down to CSS—use it if the filter approach feels heavy.
Challenge 13: Reading list has 4 items
const list = page.getByRole('list', { name: 'Reading list' });
await expect(list.getByRole('listitem')).toHaveCount(4);toHaveCount is auto-retrying. If the list were dynamically populated, this assertion would wait until 4 items appeared (up to the configured timeout).
Dynamic content
Challenge 14: Show details
await page.getByRole('button', { name: 'Show details' }).click();
await expect(page.getByText(/Station Eleven is a post-apocalyptic novel/)).toBeVisible();Click the button, assert the text appears. No waitForTimeout—toBeVisible() retries automatically.
Challenge 15: Load more
await page.getByRole('button', { name: 'Load more' }).click();
const newList = page.getByRole('list', { name: 'Newly loaded books' });
await expect(newList.getByRole('listitem')).toHaveCount(2);The button triggers a 500ms delay before items appear. toHaveCount(2) waits for it. No sleep, no timeout.
Challenge 16: Loading → Content loaded
await expect(page.getByText('Loading...')).toBeVisible();
await expect(page.getByText('Content loaded')).toBeVisible();
await expect(page.getByText('Loading...')).toBeHidden();The page starts with “Loading…” visible and swaps to “Content loaded” after 1 second. The auto-retrying assertions handle the timing. Note: if the page loads fast enough, “Loading…” might already be gone by the time your assertion runs. In that case, drop the first assertion and just assert on the end state.
Dialogs
Challenge 17: Open the dialog
await page.getByRole('button', { name: 'Rate this book' }).click();
await expect(page.getByRole('dialog')).toBeVisible();The RateBookDialog component renders a <div role="dialog">. getByRole('dialog') finds it.
Challenge 18: Select 4 stars and save
await page.getByRole('button', { name: 'Rate this book' }).click();
await page.getByLabel('4 stars').check();
await page.getByRole('button', { name: 'Save rating' }).click();
await expect(page.getByRole('dialog')).toBeHidden();Each star is a radio input with an aria-label like “4 stars.” .check() selects it. Then click “Save rating” and confirm the dialog closes.
Challenge 19: Cancel the dialog
await page.getByRole('button', { name: 'Rate this book' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('dialog')).toBeHidden();The dialog has a “Cancel” button. Click it, confirm the dialog disappears.
ARIA and roles
Challenge 20: The alert
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('alert')).toHaveText('Unsaved changes will be lost');role="alert" is a landmark role. getByRole('alert') finds it directly.
Challenge 21: Progress bar value
const progressBar = page.getByRole('progressbar', { name: 'Reading progress' });
await expect(progressBar).toHaveAttribute('aria-valuenow', '65');toHaveAttribute checks the ARIA attribute directly. The name option matches against aria-label.
Challenge 22: Toggle panel with aria-expanded
const toggle = page.getByRole('button', { name: 'Toggle panel' });
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('#expandable-panel')).toBeVisible();Before clicking: aria-expanded is "false". After: "true" and the panel is visible. Note the panel is located by #expandable-panel—this is one of those cases where the id is the most natural selector because aria-controls references it directly.
Anti-patterns and fallbacks
Challenge 23: The fake button
// This won't work — the div has no role:
// await page.getByRole('button', { name: /Click me/ }).click();
// Use the test ID fallback:
await page.getByTestId('fake-button').click();The <div> is styled like a button but has no role="button", no tabindex, no keyboard handling. getByRole can’t find it. This is the concrete proof that inaccessible markup isn’t just a standards issue—it’s a testing issue.
Challenge 24: Icon-only button
await expect(page.getByTestId('icon-only-button')).toBeVisible();The button exists as a real <button> element, but it has no text content and no aria-label. getByRole('button') would find it, but you can’t disambiguate it from other buttons by name. getByTestId is the only reliable path until someone adds an aria-label.
Patterns to take away
- Role first, always. If you can write
getByRole, do. It’s the most stable locator and it doubles as an accessibility check. - Chain to disambiguate. When multiple elements match, scope by parent instead of reaching for
.nth(). The parent-scoped version survives DOM reordering. - Text is a locator, not just an assertion.
getByTextis tier four, but for unique visible text it’s perfectly fine—and often clearer than constructing a role chain. - Test IDs earn their place. They’re not shameful. They’re the right answer for genuinely un-labelable elements. The shame is leaving the element un-labelable when a fix is possible.
- The assertion is the wait. Every
expect(locator).*call auto-retries. You never needwaitForTimeoutto handle timing.