Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 087 / 100

Testing dropdowns and comboboxes: lessons from production

Native and custom selects.

Expona Admin7 min read

Testing dropdowns and comboboxes: lessons from production

Dropdown and combobox interactions are deceptively simple on the surface but often become a source of flaky tests and missed edge cases in production environments. We've learned through real-world automation projects that the difference between a native <select> element and a custom JavaScript-built combobox determines not just which Playwright method you'll use, but how you structure your entire test around timing and visibility.

In this article, we'll walk through the patterns we've settled on for testing dropdowns and comboboxes in high-stakes applications—from e-commerce product filters to SaaS role selectors—and show you how to avoid the pitfalls that catch most teams.

Native select elements: using selectOption

When you're working with a true HTML <select> element, Playwright gives us a straightforward path: selectOption(). This method is optimized for native selects and handles the interaction in a way that mirrors how browsers actually handle select changes.

Here's a real scenario from an e-commerce site where we needed to change product size:

typescript

// Simple native select
await page.selectOption('select#product-size', 'Large');

// Or by visible text
await page.selectOption('select#product-size', { label: 'Large' });

// Or by value attribute
await page.selectOption('select#product-size', { value: 'L' });

The beauty of selectOption() is that it works even when the select is visually hidden (like in mobile views where selects collapse). Playwright handles the DOM interaction directly, which means you don't need to worry about click timing or animations.

However, we've found that the biggest mistake teams make is assuming their select is native when it's actually custom. A quick check in your browser's dev tools will show you if the underlying element is truly a <select> or if it's a <div> styled to look like one. If it's the latter, selectOption() won't work at all, and you'll get a cryptic error message about the element not supporting this operation.

For cases where you have multiple selects and need to chain operations, we wrap the interaction in a helper:

typescript

async function selectProductOption(page: Page, fieldLabel: string, value: string) {
  const selectLocator = page.locator(`label:has-text("${fieldLabel}") + select`);
  await selectLocator.selectOption(value);
  // Wait for any cascading updates
  await page.waitForLoadState('networkidle');
}

// Usage in a product configuration flow
await selectProductOption(page, 'Size', 'Large');
await selectProductOption(page, 'Color', 'Blue');

Custom comboboxes: click and pick from options

Custom comboboxes—built with divs, inputs, and JavaScript event handlers—require a different approach. These are common in modern SaaS applications, design systems like Material-UI, and any framework that wanted to "improve" on native selects.

The pattern we use most often is: locate the input or button, click it to reveal options, then locate and click the desired option:

typescript

// Open the custom combobox
await page.locator('[data-testid="role-selector"]').click();

// Wait for options to appear (important!)
await page.locator('[role="option"]').first().waitFor({ state: 'visible' });

// Click the option
await page.locator('[role="option"]:has-text("Admin")').click();

We had a real incident in a CRM system where tests were passing locally but failing in CI. The culprit: no wait between opening the combobox and trying to click an option. In the CI environment, network latency meant the options list hadn't rendered yet. Adding the waitFor() fixed it.

For comboboxes with many options, we build a helper that's more resilient:

typescript

async function selectFromCombobox(page: Page, triggerSelector: string, optionText: string) {
  // Click to open
  await page.locator(triggerSelector).click();
  
  // Wait for the listbox/options container to be visible
  await page.locator('[role="listbox"], [role="menu"]').first().waitFor({
    state: 'visible',
    timeout: 5000
  });
  
  // Locate and click the specific option
  const option = page.locator('[role="option"]').filter({ hasText: optionText }).first();
  await option.scrollIntoViewIfNeeded();
  await option.click();
  
  // Ensure the dropdown closed
  await page.locator('[role="listbox"]').first().waitFor({ state: 'hidden' });
}

// Usage
await selectFromCombobox(page, '[data-testid="customer-type"]', 'Enterprise');

The key insight here is that custom comboboxes often have their own timing quirks. Some fade in, some have keyboard navigation, some close automatically. By waiting explicitly for visibility and ensuring the menu closes, we avoid the race conditions that cause flakiness.

Using role-based option locators

Both native and custom comboboxes benefit from using accessibility role selectors. The [role="option"] and [role="listbox"] attributes are reliable hooks that work across different implementations.

Here's a test pattern that works whether the combobox is native or custom:

typescript

async function selectOptionByRole(page: Page, optionText: string) {
  // This works for both native and custom—just click the option
  await page.locator('[role="option"]').filter({ hasText: optionText }).click();
}

async function verifyOptionVisible(page: Page, optionText: string) {
  const option = page.locator('[role="option"]').filter({ hasText: optionText });
  await expect(option).toBeVisible();
}

// In a test
await page.locator('[data-testid="status-combobox"]').click();
await selectOptionByRole(page, 'Active');
await verifyOptionVisible(page, 'Active'); // Verify it was selected

We've found role-based selectors especially valuable in ride-hailing and travel apps where the UI changes frequently but the accessibility structure remains consistent. A designer might reskin the dropdown completely, but the [role="option"] structure stays, so our tests don't break.

One important note: when dealing with role selectors, always be specific about the parent container. A page might have multiple listboxes, and you want to ensure you're picking from the right one:

typescript

// More specific: get the option from within a specific combobox
await page
  .locator('[data-testid="destination-combobox"]')
  .locator('[role="option"]')
  .filter({ hasText: 'New York' })
  .click();

Type to filter in autocomplete fields

Autocomplete comboboxes add another layer: users can type to filter options. This is common in address selectors, user search fields, and large product catalogs.

The pattern here is to type into the input, wait for filtered results, then select:

typescript

async function selectFromAutocomplete(page: Page, inputSelector: string, searchText: string, optionText: string) {
  // Click to activate the input
  await page.locator(inputSelector).click();
  
  // Type the search term
  await page.locator(inputSelector).type(searchText, { delay: 50 });
  
  // Wait for results to filter
  await page.waitForFunction(
    (selector) => {
      const options = document.querySelectorAll('[role="option"]');
      return options.length > 0 && options[0].textContent.includes(optionText);
    },
    inputSelector,
    { timeout: 5000 }
  );
  
  // Select the option
  await page.locator('[role="option"]').filter({ hasText: optionText }).click();
}

// Usage in a booking system
await selectFromAutocomplete(page, '#origin-airport', 'JFK', 'John F. Kennedy (JFK)');

In a travel platform, we had an issue where tests would fail intermittently because they weren't waiting for debounce. The autocomplete was debounced by 300ms on the backend, so typing quickly and immediately clicking didn't work. The waitForFunction pattern above ensures we've actually received filtered results before trying to click.

For cases where you need to clear and retry:

typescript

async function selectFromAutocompleteSafe(page: Page, inputSelector: string, searchText: string, optionText: string) {
  const input = page.locator(inputSelector);
  
  // Clear any existing text
  await input.fill('');
  
  // Type new search
  await input.type(searchText);
  
  // Wait for at least one matching option to appear
  await page.locator('[role="option"]').filter({ hasText: optionText }).first().waitFor({ state: 'visible' });
  
  // Click it
  await page.locator('[role="option"]').filter({ hasText: optionText }).first().click();
}

Common mistakes

  • Forgetting the distinction between native and custom. Using selectOption() on a custom combobox will fail silently or throw a cryptic error. Always inspect the HTML to determine what you're dealing with.
  • Not waiting for options to render. Clicking to open a combobox and immediately clicking an option causes race conditions. Use waitFor() or waitForFunction() to ensure the options list exists before interacting.
  • Ignoring scroll position. In long dropdown lists, the option you're trying to click might be off-screen. Call scrollIntoViewIfNeeded() before clicking.
  • Over-relying on text matching. If two options have similar text, filter by a more specific attribute or use .first() to be explicit about which one you want.
  • Not closing the dropdown after selection. Some tests don't verify that the dropdown actually closed, which can cause subsequent interactions to fail or be hidden behind the open menu.

Key takeaways

  • Use selectOption() only for native <select> elements; inspect the HTML first to confirm.
  • For custom comboboxes, click to open, wait for options to be visible, then click the desired option.
  • Leverage role-based selectors like [role="option"] and [role="listbox"] for accessibility-compliant interaction patterns that are resilient to UI changes.
  • Always wait explicitly between state changes: open → filtered results → selection → closed.
  • In autocomplete fields, account for debounce delays and ensure results have been filtered before attempting selection.
  • Create helper functions to encapsulate dropdown logic; they reduce duplication and make tests easier to maintain.
  • Test in CI environments regularly; local performance can mask timing issues that surface under network latency.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles