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, shows how the checked state actually transitions, and walks the open-and-select lifecycle a portalled menu goes through.
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.
The confusion is compounded by how similar the two look on screen. A component library styles its listbox to be pixel-identical to a native dropdown, so the failing test looks correct in a screenshot while the underlying element type is completely different. The only reliable discriminator is the DOM: open the inspector, or run await page.getByLabel('Country').evaluate(el => el.tagName) once and record the answer. Driving each control with the matching API is the foundation of Form Automation & Input Handling, which sits under Advanced Interactions & Test Assertions. The matrix below is the lookup table worth pinning to the wall.
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. Every interaction is followed by an assertion on resulting state rather than on the click that produced it, which is the habit that makes form tests survive a redesign.
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. - Scope the query when several controls share a name. Two "Country" selects on one page make the locator ambiguous and the run fails on strict mode. Anchor on the enclosing
getByRole('group', { name: 'Billing address' })before the field query, as described in Resolving Strict Mode Violations. - 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, and the retrying behaviour behind these matchers is covered in Web-First Assertions.
How the checked state actually changes
check(), uncheck(), and setChecked() are not three ways of writing the same click. check() asserts the control ends up checked and throws if it does not; called on an already-checked box it performs no click at all rather than toggling it off. uncheck() is its mirror. setChecked(value) reads the current state, compares it to the target, and acts only on a mismatch — which is why it is the safe choice when a previous test, a saved session, or a server-rendered default may have left the box in either position.
That guard matters most in tests that run twice. A test that calls check() unconditionally passes on a fresh page and still passes on a pre-checked one; a raw click() in the same place silently unchecks it and the form submits the opposite of what you intended. Tri-state checkboxes add a third position: an element with indeterminate set reports neither checked nor unchecked, so assert toBeChecked({ checked: false }) or read aria-checked="mixed" directly instead of expecting a boolean.
The open-and-select lifecycle for custom widgets
A custom dropdown is a small state machine spread across two parts of the DOM. The trigger owns aria-expanded; the option list is usually rendered by a portal that appends to document.body, far from the trigger in the tree even though it appears anchored to it on screen. Understanding that split explains almost every failure: scoping the option query to the trigger's subtree finds nothing, and asserting on the menu before the mount animation finishes reads a stale tree.
The reliable path is to click the trigger, then query the option from the page root and click it directly. Playwright's actionability checks make the second click wait for the option to exist, be visible, and stop moving, so you rarely need an explicit wait between the two steps. Where a widget fetches its options over the network, the wait becomes a data wait rather than an animation wait, and the patterns in Waiting Strategies for Dynamic React Components apply directly. Widgets shipped as web components hide their internals behind a shadow root, which locators pierce automatically — see Shadow DOM Traversal if the option nodes never appear in a plain DOM dump.
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. A quick way to confirm which you have is to evaluate el.tagName on the locator once during development and hard-code the branch from there rather than writing a test that tries both.
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 panel that appears, the price that updates — and file the missing role as an accessibility bug, because a control with no exposed state is unusable with a screen reader too.
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.
The UI updates but the submitted payload keeps the old value
The widget updated its own visual state without notifying the framework's controlled-input layer, which happens when a test dispatches a bare click() on a native input rather than using check() or selectOption(). Those methods fire the input and change events React and Vue listen for; a synthetic pointer event alone may not. Confirm by capturing the outgoing request with a route handler as shown in Intercepting and Modifying Network Requests and comparing the body against what the DOM claims.
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.
When a form carries a dozen fields, collect the checks with Soft Assertions for Multi-Check Tests so one wrong control reports alongside the rest instead of hiding them behind the first failure. If a control still misbehaves only in CI, open the recording in the Playwright Trace Viewer and step through the action snapshots — the frame before the click shows whether the option list had actually mounted.
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.
How do I choose several options at once?
On a native <select multiple>, pass an array to selectOption() — for example selectOption(['red', 'blue']) — and the call replaces the whole selection in one step, so omitted values become deselected. Passing an empty array clears every option. A custom multi-select needs one click per entry with the menu reopened if it closes on each choice, and the assertion moves to the rendered chips rather than a single value.
Do these APIs work inside rich text editors and content-editable fields?
No. A content-editable region has no checked or selected state to set, so its toolbar toggles are buttons whose pressed state you read from aria-pressed. Those controls are covered separately in Automating Rich Text Editors.