What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.
Use selectOption() only for a real HTML <select>. A custom dropdown (including most React and Vue comboboxes) is a button or input that opens a popup, so test it as a user would: locate the trigger by its accessible role and name, open it, choose a rendered option, and assert the resulting value or selected state.
First, identify which control you have
Inspect the rendered DOM, not the visual appearance. A native control contains a <select> element and <option> children. A custom widget usually uses an input or button, then renders a listbox elsewhere in the DOM. The distinction determines the API and the assertions.
Native HTML select
For a native select, use a label or other stable locator and call selectOption(). You can select by value, label, or index; the example below selects by visible label and verifies the submitted value.
The Tool Desk
Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →import { test, expect } from '@playwright/test';
test('selects a country from a native select', async ({ page }) => {
await page.goto('/profile');
const country = page.getByLabel('Country');
await country.selectOption({ label: 'Canada' });
await expect(country).toHaveValue('ca');
});
selectOption() is not a generic “pick something that looks like a dropdown” command. Calling it on a button, input, or listbox will fail because those elements do not expose native options.
#1 Best Overall
Custom select-only combobox
A select-only combobox opens a popup but does not let the user type arbitrary text. Locate the combobox, open it, wait for the listbox, select the exact option, and verify the displayed selection.
test('selects Canada from a custom combobox', async ({ page }) => {
await page.goto('/profile');
const country = page.getByRole('combobox', { name: 'Country' });
await country.click();
await expect(page.getByRole('listbox')).toBeVisible();
await page
.getByRole('option', { name: 'Canada', exact: true })
.click();
await expect(country).toHaveText('Canada');
});
If the component exposes a button rather than a combobox role, use its accessible name to open it, then scope the option lookup to the visible listbox:
const trigger = page.getByRole('button', { name: 'Country' });
await trigger.click();
const listbox = page.getByRole('listbox');
await expect(listbox).toBeVisible();
await listbox.getByRole('option', { name: 'Canada', exact: true }).click();
await expect(trigger).toHaveText('Canada');
Editable combobox with filtering
An editable combobox is an input. Type the search text, wait for the matching suggestion to render, activate it, and assert the input value.
test('chooses a filtered assignee', async ({ page }) => {
await page.goto('/tasks/new');
const search = page.getByRole('combobox', { name: 'Assignee' });
await search.fill('Ada');
const listbox = page.getByRole('listbox');
await expect(listbox).toBeVisible();
await listbox
.getByRole('option', { name: 'Ada Lovelace', exact: true })
.click();
await expect(search).toHaveValue('Ada Lovelace');
});
Typing through the input is preferable to setting a property in the DOM: it exercises filtering, focus management, and the events your application actually receives.
Choose locators that describe the user contract
Start with semantic locators and an accessible name. They survive markup refactors better than CSS chains tied to implementation details.
Rank #2
getByRole('combobox', { name: 'Country' })for a correctly exposed combobox.getByRole('button', { name: 'Country' })when a button opens the popup.getByLabel('Country')for a labeled input or native control.getByTestId('country-select')when the team deliberately provides a stable test contract and no useful semantic name exists.
Use exact: true when option names can overlap (for example, “Canada” and “Canada (French)”). If several widgets contain the same option text, scope the locator to the relevant visible listbox. This both removes ambiguity and documents which control the test is exercising.
Open before you locate lazy-rendered options
Many components do not render options until they are opened, or keep them hidden while collapsed. Locate and click the trigger first, then assert popup visibility. This gives a useful failure when the menu cannot open and prevents a locator from accidentally matching a hidden or unrelated option.
Recommended Free Tools
const combobox = page.getByRole('combobox', { name: 'Project' });
await combobox.click();
const listbox = page.getByRole('listbox');
await expect(listbox).toBeVisible();
const option = listbox.getByRole('option', { name: 'Website', exact: true });
await expect(option).toBeVisible();
await option.click();
Do not add arbitrary sleeps for rendering. Playwright waits for actionable elements and assertions retry until their timeout. A visibility assertion expresses the actual UI contract more clearly than a delay.
Assert the state change, not just the click
A successful click does not prove that the application accepted the choice. Assert the state your user or form submission depends on:
- The selected label on a button or select-only combobox:
toHaveText(). - The value in an editable combobox:
toHaveValue(). - The popup closing:
await expect(listbox).toBeHidden()when closing is part of the widget behavior. - The selected option:
toHaveAttribute('aria-selected', 'true')if the component exposes that state. - A form-level result, such as a summary or validation message, when selection triggers application logic.
await option.click();
await expect(combobox).toHaveText('Website');
await expect(listbox).toBeHidden();
Use the assertion that matches the component’s public contract. Avoid asserting internal class names that carry no user-visible meaning.
Keyboard interaction is part of the test surface
Accessible comboboxes support keyboard use as well as pointer use. Cover it when keyboard operation matters to your product or accessibility requirements. Typical interactions are Down Arrow (or Enter) to open, Arrow keys to move, Enter to accept, and Escape to dismiss.
const country = page.getByRole('combobox', { name: 'Country' });
await country.focus();
await country.press('ArrowDown');
await expect(page.getByRole('listbox')).toBeVisible();
await country.press('ArrowDown');
await country.press('Enter');
await expect(country).toHaveText('Canada');
For an editable widget, verify that Escape dismisses suggestions without changing the current value. Also check focus behavior if your component promises that focus returns to the input after selection.
Common failures and precise fixes
“selectOption: Element is not a <select>”
Cause: the control is custom. Fix: replace selectOption() with trigger, popup, option, and post-selection steps. If the product should be native, change the implementation to a real select instead.
Locator matches multiple options
Cause: duplicate text in several dropdowns, or a broad substring match. Fix: scope to the visible listbox and use exact: true. Give each control an accessible name.
Option is not found
Cause: options are rendered only after opening, loaded after filtering, or virtualized. Fix: open first, assert listbox visibility, type the filter through the input, and wait on the specific option assertion. For virtualized lists, scroll the list using the component’s real interaction or choose an option that the filter renders.
Crashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minuteWindows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallRank #4
Click is intercepted or times out
Cause: an overlay, animation, or closed popup. Fix: assert visibility, wait for the option to become actionable through a normal locator, and inspect the screenshot or trace. Avoid force: true; it can hide a real z-index or accessibility defect.
Selection appears to work but value is unchanged
Cause: the test clicked a decorative element, a stale duplicate, or an option before the component finished updating. Fix: scope to the visible listbox, click the role-based option, and assert the input value or selected label. If the widget uses an asynchronous data source, wait for the option’s text and state rather than adding a fixed delay.
Role locator finds nothing
Cause: missing or incorrect ARIA roles, or an inaccessible name. Fix: inspect the accessibility tree and component markup. Add a proper label and the combobox/listbox/option relationships in the application. Use a test id only as an explicit fallback, not as a substitute for an accessible control.
A maintainable test pattern
For repeated widgets, wrap the interaction in a small helper that accepts a locator and visible option name. Keep the helper semantic so individual tests remain readable.
Do these 3 things before closing this tab:
1Repair Windows errors before they cause bigger problems2Scan for outdated or missing drivers - takes under a minute3Clear out junk files and repair common Windows errorsasync function chooseOption(
page,
trigger,
optionName
) {
await trigger.click();
const listbox = page.getByRole('listbox');
await expect(listbox).toBeVisible();
await listbox
.getByRole('option', { name: optionName, exact: true })
.click();
}
const country = page.getByRole('combobox', { name: 'Country' });
await chooseOption(page, country, 'Canada');
await expect(country).toHaveText('Canada');
Keep the final assertion in the test (or make it an explicit helper argument) so a passing interaction cannot conceal a failed state update. If multiple listboxes can be open, pass a listbox locator into the helper and scope it to the component container.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.How to evaluate a custom dropdown implementation
| Question | What a strong implementation provides |
|---|---|
| Semantics | A combobox or button trigger, a listbox popup, and option roles that match the widget type. |
| Name | A stable accessible name from a visible label or an intentional accessible-name attribute. |
| Rendering | Options become available after opening without requiring arbitrary test delays. |
| Uniqueness | Option text is scoped to the correct listbox and duplicates are handled deliberately. |
| State | Selected value, expanded state, and (where applicable) aria-selected reflect reality. |
| Keyboard | Opening, navigation, acceptance, and dismissal work without a pointer. |
| Test contract | A semantic locator is available; a stable test id exists only when semantics cannot express the needed target. |
Troubleshoot with traces and screenshots
When a dropdown fails intermittently, inspect the Playwright trace to see the DOM, accessibility information, actionability checks, and timing around the popup. A screenshot at the failure point can reveal an overlay, an unexpected responsive layout, or a menu rendered outside the component. Fix the locator or component contract first; do not mask the issue with forced clicks.
Or skip the browser setup
If you need a clean image of a page or test fixture rather than an interactive assertion, ScreenshotNeo provides a single HTTP call. It accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be disabled. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status.
Use the API directly (see the ScreenshotNeo documentation):
Free tools Windows power users keep installed
One-click scans. No signup required.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo also has an MCP server with take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
Frequently Asked Questions
Can I select a custom dropdown with Playwright’s selectOption method?
No. selectOption is for a native HTML select. Open the custom widget and activate its rendered option with role- or label-based locators.
Should I use CSS selectors or XPath for dropdown options?
Use semantic roles and accessible names first. Scope to a visible listbox and use an explicit test id only when the application has no stable accessible contract.
How do I test a searchable React combobox?
Fill the input, wait for the visible listbox and exact option, click it, then assert the input value and any selected state exposed by the component.
The Bottom Line
Native selects use selectOption(); custom dropdowns require the user sequence of open, locate, choose, and verify. Semantic roles, scoped options, keyboard coverage, and state assertions produce tests that remain reliable as the UI evolves.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

