Handling Dropdowns, Checkboxes, and Radio Buttons
Form controls split into two worlds: native HTML elements the browser renders itself, and custom widgets built from <div>s and ARIA roles. A native <select> is driven with selectOption(), native checkboxes and radios with check(), uncheck(), and setChecked(). The moment a design system replaces those with a styled role="listbox" or a clickable card, those APIs no longer apply and you must drive the widget the way a user would — open it, then pick the option by its accessible role. Mixing the two strategies up is the most common reason a form test clicks the right pixel and changes nothing. This page maps each control type to the correct API.
Root cause: one API does not fit every control
selectOption() calls the DOM HTMLSelectElement selection API directly and dispatches input/change — it works only on a real <select>. check() toggles a real <input type="checkbox"> or type="radio" and is a no-op-style guard that verifies the element ends up checked. A custom dropdown is none of those: it is usually a button that toggles a role="listbox" containing role="option" nodes, with no <select> anywhere in the DOM. Calling selectOption() on it throws because there are no <option> elements to choose. Likewise a "checkbox" drawn as a styled <div role="checkbox"> will not respond to check() unless it exposes the checkbox role and aria-checked. Driving each control with the matching API is the foundation of Form Automation & Input Handling, which sits under Advanced Interactions & Test Assertions.
Minimal reproducible example
The test fills a signup form that mixes all three control types: a native country <select>, a terms checkbox, a plan radio group, and a custom timezone dropdown built from ARIA roles.
import { test, expect } from '@playwright/test';
test('signup form handles every control type', async ({ page }) => {
await page.goto('/signup');
// Native <select>: choose by visible label, value, or index.
await page.getByLabel('Country').selectOption({ label: 'Germany' });
// Native checkbox: setChecked(true) is idempotent — it only acts if needed.
await page.getByRole('checkbox', { name: 'Accept terms' }).setChecked(true);
// Native radio group: check() the option you want; siblings clear themselves.
await page.getByRole('radio', { name: 'Pro plan' }).check();
// Custom dropdown: open the trigger, then pick the option by its ARIA role.
await page.getByRole('button', { name: 'Timezone' }).click();
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
// Assert resulting state, not the clicks we performed.
await expect(page.getByLabel('Country')).toHaveValue('DE');
await expect(page.getByRole('checkbox', { name: 'Accept terms' })).toBeChecked();
await expect(page.getByRole('radio', { name: 'Pro plan' })).toBeChecked();
await expect(page.getByRole('button', { name: 'Timezone' }))
.toHaveText(/Europe\/Berlin/);
});
Step-by-step fix
- Identify the control in the DOM first. Inspect the element. A real
<select>with<option>children uses one API; a<div role="listbox">withrole="option"children uses another. This single check determines everything that follows. - Drive native
<select>withselectOption(). Pass{ label },{ value },{ index }, or an array for a multi-select. Selecting bylabelkeeps the test readable and resilient to value renames; the call dispatches thechangeevent the app listens for. - Toggle native checkboxes and radios with
setChecked(). PrefersetChecked(true|false)overcheck()/uncheck()when the starting state is unknown — it is idempotent and will not toggle an already-correct control. For radios,check()the desired option and the group's others clear automatically. - Open custom widgets, then select by role. Click the trigger (
getByRole('button')orgetByRole('combobox')) to open the menu, wait for the listbox, thengetByRole('option', { name })and click it. This is the same path a keyboard or mouse user takes. - Locate by accessible name, not brittle CSS. Use
getByLabelfor fields andgetByRole(... , { name })for options so a class or markup change does not break the test. See getByRole & Accessibility Selectors for why role-based queries survive refactors. - Assert the resulting state. Follow each interaction with
toBeChecked(),toHaveValue(), or a visible-text assertion on the trigger. Verifying state — not the click — is what proves the control actually changed.
Troubleshooting variants
selectOption() throws "element is not a <select>"
The control is a custom widget, not a native dropdown. There is no <select> to operate on. Switch to the open-then-pick pattern: click the trigger and choose the entry with getByRole('option', { name }). Reserve selectOption() for genuine <select> elements.
check() reports the element is not a checkbox
The control is a styled <div> or <button> without the checkbox role, or it exposes role="switch" instead. Target it by its actual role (getByRole('switch')) and click it, then assert with toBeChecked() or toHaveAttribute('aria-checked', 'true'). If it has no ARIA state at all, assert the downstream visible effect instead.
The custom option list closes before I can click it
The dropdown closed on blur because the locator query stole focus, or the options render in a portal outside the trigger's subtree. Query the option from page (not scoped to the trigger) since portals attach at the document root, and avoid intermediate hovers that move focus away. If it animates open, let Playwright auto-wait by clicking the option locator directly rather than asserting visibility first. This pattern recurs across the steps in Automating Multi-Step Forms with Playwright.
Verification
Confirm each control changed three ways. First, the state assertions (toBeChecked, toHaveValue, trigger text) pass on repeated runs (npx playwright test --repeat-each=10), proving the interaction is not racing the widget's open animation. Second, submit the form and assert the request payload carries the selected values via a route handler, so you know the DOM state propagated to the app. Third, run headed with --headed --slow-mo=200 and watch the select change, the boxes toggle, and the custom menu open and resolve to the chosen option.
Frequently Asked Questions
Why does selectOption() fail on my dropdown?
Because it only works on a native <select> element. If the dropdown is built from a <div role="listbox"> with role="option" children, there is no <select> for selectOption() to operate on. Open the trigger with a click and select the entry with getByRole('option', { name }) instead.
Should I use check()/uncheck() or setChecked()?
Use setChecked(true|false) when you do not know the starting state, because it is idempotent and only acts if the control is not already in the target state. Use check() and uncheck() when you want to assert a transition occurs, or for radio groups where check() clears the sibling options automatically.
How do I select an option in a custom ARIA dropdown?
Drive it like a user: click the trigger button or combobox to open the menu, then click the entry located with getByRole('option', { name }). If the options render in a portal, query them from the page root rather than scoping to the trigger, and assert the trigger's visible text afterward.