๐Ÿ”ฅ 0
โญ 0
Lesson 6 of 9 30 min +250 XP

Handling Tricky Components

Most of a suite is login forms and buttons. Then you hit the widgets that fight back: a modal that isn't in the DOM until you open it, a toast that vanishes in three seconds, an autocomplete that loads suggestions a beat late, a table whose rows reshuffle when you sort. Naive tests flake or fail on these. This lesson is a field guide to the tricky ones.

> Practice ground: the [Components Gym](https://resources.criodo.com/shopkart/components) has all of these as live widgets, each with a focused task and a stable data-test hook. Every example below is verified against it.

The good news: almost every one is solved by two habits you already have โ€” web-first assertions (Lesson 4) and stable locators (Lesson 3). Here's how they apply.

Elements that appear and disappear

A modal doesn't exist until it's opened, and its content sits on top of the page. Don't assume it's there โ€” wait for it, then scope your assertions to it:

test('modal opens, confirms, and closes', async ({ page }) => {
  await page.goto('/shopkart/components');
  await page.locator('[data-test="modal-open"]').click();

  const modal = page.locator('[data-test="modal"]');
  await expect(modal).toBeVisible();                       // wait for it
  await expect(modal.locator('[data-test="modal-title"]')).toHaveText('Confirm your action');

  await page.locator('[data-test="modal-confirm"]').click();
  await expect(modal).toBeHidden();                        // and gone
});

A toast is the opposite problem โ€” it appears, then auto-dismisses. This is the classic trap for waitForTimeout: too slow and it's already gone, too fast and it isn't there yet. Web-first assertions handle both ends:

test('toast appears then auto-dismisses', async ({ page }) => {
  await page.goto('/shopkart/components');
  await page.locator('[data-test="toast-trigger"]').click();

  const toast = page.locator('[data-test="toast"]');
  await expect(toast).toBeVisible();      // retries until it shows
  await expect(toast).toHaveCount(0);     // retries until it's gone (~3s later)
});

Never sleep(3000) to "wait for the toast to go" โ€” assert the condition and let the runner poll.

Async widgets: wait for the result, not a duration

An autocomplete fetches suggestions after you type. The options aren't there on the keystroke โ€” so wait for them before clicking:

test('autocomplete suggests and selects', async ({ page }) => {
  await page.goto('/shopkart/components');
  await page.locator('[data-test="ac-input"]').fill('de');

  const options = page.locator('[data-test="ac-option"]');
  await expect(options).toHaveCount(2);           // retries until suggestions load (Delhi, Dehradun)
  await options.first().click();

  await expect(page.locator('[data-test="ac-selected"]')).toContainText('Delhi');
});
Delayed content (a panel that renders a few seconds after a click) is the same idea โ€” assert on the element you're waiting for, with a timeout generous enough for the delay:
await page.locator('[data-test="delay-trigger"]').click();
await expect(page.locator('[data-test="delayed-content"]')).toBeVisible({ timeout: 6000 });

Stateful widgets: assert the state

Tabs, accordions, checkboxes, radios all carry state. Test the behavior, then assert the resulting state โ€” don't assume the click "worked":
test('shipping options update the summary', async ({ page }) => {
  await page.goto('/shopkart/components');
  await page.locator('[data-test="check-giftwrap"]').check();
  await page.locator('[data-test="radio-express"]').check();
  await expect(page.locator('[data-test="selection-summary"]'))
    .toHaveText('Shipping: Express ยท Gift wrap');
});

A disabled-until-valid button is a requirement worth asserting on both sides โ€” it should be disabled first, enabled after the precondition:

const submit = page.locator('[data-test="submit-gated"]');
await expect(submit).toBeDisabled();
await page.locator('[data-test="agree"]').check();
await expect(submit).toBeEnabled();

And a stepper with bounds is pure boundary-value analysis (Lesson 2) โ€” assert the button disables at the edges:

await expect(page.locator('[data-test="qty-dec"]')).toBeDisabled();   // can't go below 1
// ...click "+" up to the max...
await expect(page.locator('[data-test="qty-inc"]')).toBeDisabled();   // can't go above 10

Tables: assert on content, never position

The single biggest table mistake is locating a cell by its position โ€” row 2, column 1. Sort the table or add a row and that test lies. Anchor to content, then read across:

test('sorting by price puts the cheapest first', async ({ page }) => {
  await page.goto('/shopkart/components');
  await page.locator('[data-test="th-price"]').click();          // sort by price ascending
  await expect(page.locator('[data-test="row-price"]').first()).toHaveText('โ‚น178');

  await page.locator('[data-test="page-next"]').click();          // pagination
  await expect(page.locator('[data-test="page-info"]')).toHaveText('Page 2 of 3');
});

When you need a specific row, filter by its text rather than its index:

const row = page.locator('[data-test="table-row"]').filter({ hasText: 'Football' });
await expect(row.locator('[data-test="row-price"]')).toHaveText('โ‚น569');

Dynamic IDs: hook to what's stable

Some elements get a fresh id on every render (common with component frameworks). A selector like #btn-4821 passes once and breaks on the next load. This is exactly why Lesson 3 preached data-test: it's the one hook the app controls for testing and won't shuffle:

// The id changes every load; the data-test hook doesn't.
await page.locator('[data-test="dynamic-btn"]').click();
await expect(page.locator('[data-test="dynamic-result"]')).toHaveText('Clicked via data-test โœ“');

File upload: set the file, don't click

You can't drive the OS file dialog โ€” and you don't need to. setInputFiles puts a file straight on the :

await page.locator('[data-test="file-input"]').setInputFiles({
  name: 'resume.pdf', mimeType: 'application/pdf', buffer: Buffer.from('hello'),
});
await expect(page.locator('[data-test="file-name"]')).toContainText('resume.pdf');

The through-line

Look back at every fix in this lesson. There are really only three moves:

  • Wait for the condition, never a duration โ€” toBeVisible, toHaveCount, toBeEnabled retry; waitForTimeout doesn't.
  • Anchor to stable, content-based hooks โ€” data-test and text, not IDs or positions.
  • Assert the resulting state โ€” prove the interaction did what it should, don't assume.

Master those three and the "tricky" components stop being tricky. The widget zoo is just the same two habits applied under pressure.

> ๐ŸŽฏ Practice: the [Components Gym](https://resources.criodo.com/shopkart/components) has 13 widgets, each with a task caption. Automate the modal, the auto-dismiss toast, the autocomplete, the sortable/paginated table, and the file upload โ€” then the rest will feel routine.

Key takeaways

  • Appearing/disappearing elements (modals, toasts): wait for visibility, scope assertions to them, and assert they're gone โ€” never sleep for a toast.
  • Async widgets (autocomplete, delayed content): assert on the result with an auto-retrying assertion, not a fixed wait.
  • Stateful widgets: perform the action, then assert the resulting state (summary text, enabled/disabled, selected).
  • Tables: anchor to content or a data-test row, never a positional index that breaks on sort/paginate.
  • Dynamic IDs: target the stable data-test hook, not a changing id; file uploads: use setInputFiles.

Next lesson

Your suite now covers UI flows, the API layer, and the trickiest widgets. Time to make it run for real โ€” in parallel, in CI, as a gate that blocks a bad deploy. Next: running it for real.