|

Testing Autocomplete and Typeahead Inputs in Playwright

Autocomplete and typeahead inputs are quietly one of the flakiest things to automate: you type a query, a debounced network call fires, a dropdown of suggestions paints asynchronously, and your test races the UI to click an option that may not exist yet. This guide on Playwright autocomplete typeahead testing shows you how to type into search boxes, wait for suggestions the right way, pick options deterministically, mock the suggestion API, and tame debounce so your tests stop being flaky. Everything here uses real Playwright + TypeScript APIs you can paste into a project today.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

Why Autocomplete Inputs Break Tests

A typeahead component is not a single control; it is a small state machine. The user types, the component debounces input for a few hundred milliseconds, sends an XHR or fetch request, receives JSON, renders a listbox of options, and then waits for a click, an arrow-key selection, or an Enter press. Each of those stages is asynchronous, and a naive test that types and immediately clicks the first option will fail the moment the network is slightly slow or the debounce timer has not elapsed.

The good news is that Playwright was built for exactly this kind of asynchronous UI. Its locators auto-wait for elements to be visible, stable, and actionable, and its web-first assertions retry until a condition is met or a timeout expires. The trick to reliable Playwright autocomplete typeahead testing is to stop using fixed sleeps and instead express your intent: wait for the listbox, wait for the specific option text, then act on it.

Anatomy of a Typeahead Component

Most accessible autocomplete widgets follow the ARIA combobox pattern. The text field has role="combobox", the suggestion container has role="listbox", and each suggestion has role="option". Targeting these roles instead of brittle CSS classes makes your tests resilient to styling changes and aligns them with how assistive technology reads the page.

StageWhat happensPlaywright tool to use
Type queryUser enters characters into the comboboxlocator.fill() or pressSequentially()
DebounceComponent waits before firing a requestpage.clock or waitForResponse
Fetch suggestionsXHR/fetch returns JSON resultspage.waitForResponse / page.route
Render listboxOptions appear in a role="listbox"expect(option).toBeVisible()
Select optionUser clicks or keyboard-selects a resultgetByRole('option') + click / keyboard

Typing and Selecting a Suggestion

The most common mistake is calling fill() and clicking the option in the next line. Instead, type the query, then let a web-first assertion wait for the option to be visible before you click it. Because getByRole('option', { name }) is a locator, Playwright retries until the matching suggestion renders, no waitForTimeout required.

import { test, expect } from '@playwright/test';

test('selects a city from the autocomplete', async ({ page }) => {
  await page.goto('https://app.example.com/search');

  const combobox = page.getByRole('combobox', { name: 'City' });
  await combobox.fill('berl');

  // The listbox is async; let Playwright wait for the option to appear.
  const option = page.getByRole('option', { name: 'Berlin, Germany' });
  await expect(option).toBeVisible();
  await option.click();

  // Assert the input committed the chosen value.
  await expect(combobox).toHaveValue('Berlin, Germany');
});

If your component only fires its search on real key events rather than a programmatic value set, swap fill() for pressSequentially(), which dispatches individual keydown/keyup events just like a human typing. Use fill() when the component listens to the input event (most React/Vue widgets), and pressSequentially() when it listens to raw key presses or enforces a minimum character count per keystroke.

test('typeahead that needs real keystrokes', async ({ page }) => {
  await page.goto('https://app.example.com/search');

  const combobox = page.getByRole('combobox', { name: 'Product' });
  await combobox.click();
  // delay makes each keystroke fire input/keyup like a human.
  await combobox.pressSequentially('lap', { delay: 80 });

  await expect(page.getByRole('listbox')).toBeVisible();
  const options = page.getByRole('option');
  await expect(options).toHaveCount(3);
  await options.filter({ hasText: 'Laptop Stand' }).click();
});

Keyboard Navigation: Arrow Keys and Enter

Power users navigate typeaheads entirely from the keyboard, and so should at least one of your tests. Playwright's page.keyboard and the locator press() method let you drive ArrowDown to highlight a result and Enter to commit it. This also verifies that aria-activedescendant moves correctly, which is a real accessibility requirement many components get wrong.

test('navigates suggestions with the keyboard', async ({ page }) => {
  await page.goto('https://app.example.com/search');

  const combobox = page.getByRole('combobox', { name: 'City' });
  await combobox.fill('lon');
  await expect(page.getByRole('option').first()).toBeVisible();

  // Move down two suggestions and commit with Enter.
  await combobox.press('ArrowDown');
  await combobox.press('ArrowDown');
  await combobox.press('Enter');

  await expect(combobox).toHaveValue(/London/);
});

Notice we wait for the first option to be visible before pressing ArrowDown. Pressing keyboard keys before the listbox renders is a classic source of flake: the keystroke is swallowed because there is nothing to navigate yet. The visibility assertion synchronizes your test with the component's render cycle.

Mocking the Suggestion API for Determinism

Real suggestion endpoints change over time. The city list, product catalog, or user directory behind your typeahead will return different results next month, and that makes toHaveCount and exact-text assertions fragile. The professional approach is to intercept the request with page.route() and fulfill it with a fixed payload, so your test owns the data it asserts on.

test('renders mocked suggestions deterministically', async ({ page }) => {
  // Intercept the typeahead endpoint and return a stable list.
  await page.route('**/api/cities?q=*', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, label: 'Paris, France' },
        { id: 2, label: 'Paris, Texas' },
      ]),
    });
  });

  await page.goto('https://app.example.com/search');
  await page.getByRole('combobox', { name: 'City' }).fill('paris');

  const options = page.getByRole('option');
  await expect(options).toHaveCount(2);
  await expect(options.nth(0)).toHaveText('Paris, France');
  await expect(options.nth(1)).toHaveText('Paris, Texas');
});

With the response mocked, your assertions are stable regardless of backend state, and the test runs faster because it never touches the network. You can also use the same pattern to test edge cases that are hard to reproduce against a live API: an empty result set, a single result, a list with special characters, or a 500 error.

Testing the Empty and Error States

Good typeahead components show a "No results" message and recover gracefully from server errors. Mocking makes those states trivial to reproduce. Fulfill with an empty array to assert the no-results UI, or use route.fulfill with a 500 status to confirm the component does not crash.

test('shows a no-results message', async ({ page }) => {
  await page.route('**/api/cities?q=*', (route) =>
    route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
  );

  await page.goto('https://app.example.com/search');
  await page.getByRole('combobox', { name: 'City' }).fill('zzzzz');

  await expect(page.getByText('No results found')).toBeVisible();
  await expect(page.getByRole('option')).toHaveCount(0);
});

Taming Debounce With page.clock

Many typeaheads debounce input by 300–500 ms so they do not fire a request on every keystroke. Waiting for real time makes tests slow; worse, fixed sleeps make them flaky. Playwright's page.clock API lets you install a controllable clock, type your query, then fast-forward time so the debounce timer fires immediately and deterministically.

test('debounced search fires after the timer advances', async ({ page }) => {
  await page.clock.install();

  await page.route('**/api/cities?q=*', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([{ id: 1, label: 'Tokyo, Japan' }]),
    })
  );

  await page.goto('https://app.example.com/search');
  await page.getByRole('combobox', { name: 'City' }).fill('tok');

  // Jump past the 400ms debounce window instead of really waiting.
  await page.clock.fastForward(500);

  await expect(page.getByRole('option', { name: 'Tokyo, Japan' })).toBeVisible();
});

An equally robust alternative that needs no clock control is page.waitForResponse. Wrap the typing action in a promise that resolves when the suggestion request completes, then assert. This ties your test directly to the real network event the component depends on.

test('waits for the suggestion response explicitly', async ({ page }) => {
  await page.goto('https://app.example.com/search');

  const combobox = page.getByRole('combobox', { name: 'City' });

  const [response] = await Promise.all([
    page.waitForResponse((r) => r.url().includes('/api/cities') && r.ok()),
    combobox.fill('osa'),
  ]);

  expect(response.status()).toBe(200);
  await expect(page.getByRole('option', { name: 'Osaka, Japan' })).toBeVisible();
});

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

A Reusable Typeahead Page Object

Once you understand the moving parts, wrap them in a Page Object so every test selects suggestions the same robust way. A single chooseSuggestion() method centralizes the type-wait-click sequence, so a future change to the component only needs one fix.

import { Page, Locator, expect } from '@playwright/test';

export class CitySearch {
  readonly page: Page;
  readonly combobox: Locator;

  constructor(page: Page) {
    this.page = page;
    this.combobox = page.getByRole('combobox', { name: 'City' });
  }

  async chooseSuggestion(query: string, label: string): Promise<void> {
    await this.combobox.fill(query);
    const option = this.page.getByRole('option', { name: label });
    await expect(option).toBeVisible();
    await option.click();
    await expect(this.combobox).toHaveValue(label);
  }
}

// Usage in a test:
// const search = new CitySearch(page);
// await search.chooseSuggestion('berl', 'Berlin, Germany');

Best Practices and Common Pitfalls

  • Never use waitForTimeout to wait for suggestions. Assert on the option locator instead; Playwright auto-retries until it renders.
  • Prefer ARIA roles over CSS. getByRole('option', { name }) survives styling refactors and matches how users perceive the widget.
  • Mock the suggestion API for assertions on exact counts or text. Live data changes and will eventually break brittle assertions.
  • Match fill() vs pressSequentially() to the component. Use fill() for input-event widgets and pressSequentially() when real keystrokes are required.
  • Synchronize before keyboard navigation. Wait for the first option to be visible before pressing ArrowDown so keystrokes are not lost.
  • Use page.clock or waitForResponse for debounce rather than guessing a sleep duration.

Conclusion

Reliable Playwright autocomplete typeahead testing comes down to respecting the component's asynchronous state machine instead of fighting it. Type with the right method, wait for suggestions using web-first assertions on role="option", drive the keyboard when accessibility matters, mock the suggestion endpoint for deterministic data, and control debounce with page.clock or waitForResponse. Wrap the sequence in a Page Object and your typeahead tests become fast, readable, and stubbornly resistant to the flake that plagues less disciplined suites.

FAQ

Should I use fill() or pressSequentially() for autocomplete inputs?

Use fill() when the component reacts to the input event, which covers most modern React, Vue, and Angular typeaheads. Use pressSequentially() when the widget only responds to real keystrokes, enforces a per-character minimum, or relies on keydown/keyup handlers. pressSequentially() dispatches individual key events and accepts a delay option to mimic human typing speed, which some debounced components need to trigger their search.

How do I avoid flaky autocomplete tests caused by debounce?

Never use a fixed page.waitForTimeout. Instead, install Playwright's controllable clock with page.clock.install() and call page.clock.fastForward() to jump past the debounce window deterministically, or wrap the typing action with page.waitForResponse() so the test waits for the real suggestion request to complete. Both approaches synchronize the test with the component's actual behavior rather than guessing a duration.

How can I make suggestion assertions stable when the backend data changes?

Intercept the suggestion endpoint with page.route() and return a fixed JSON payload via route.fulfill(). With the response mocked, assertions like toHaveCount(2) or exact option text stay stable regardless of backend state, tests run faster because they skip the network, and you can easily reproduce edge cases such as empty results, single results, or server errors that are hard to trigger against a live API.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.