The previous lesson covered the workhorses — click, fill, check, select. This one zooms in on the form controls that have their own quirks: native dropdowns vs custom comboboxes, checkboxes vs toggle switches, radio groups, date pickers, autocomplete inputs. Each of these has a "right" command in Playwright if you know the underlying HTML, and a fallback pattern if it's a styled JavaScript component pretending to be a form control. By the end of this lesson, no form should slow you down — even the ones built by a designer who's never read the WCAG specs.
Native HTML select dropdowns
Native <select> elements get the .selectOption() command. Three ways to identify the option:
// By visible text — most readable
await page.getByLabel("Country").selectOption("United Kingdom");
// By value attribute — most resilient (text changes more often than value)
await page.getByLabel("Country").selectOption({ value: "uk" });
// By zero-based index — last resort, brittle to reordering
await page.getByLabel("Country").selectOption({ index: 2 });For multi-select (<select multiple>):
await page.getByLabel("Interests").selectOption(["Sports", "Music", "Tech"]);
// By value array
await page.getByLabel("Sizes").selectOption([
{ value: "s" },
{ value: "m" }
]);To assert the current selection:
await expect(page.getByLabel("Country")).toHaveValue("uk");
// Multi-select — assert with toHaveValues (plural)
await expect(page.getByLabel("Interests")).toHaveValues(["sports", "music"]);How do you know it's a native <select>? Inspect the element. If the tag is literally <select>, you can use .selectOption(). If it's a <div>, <button>, or <ul> styled to look like a dropdown, you need the custom-dropdown pattern below.
Custom dropdowns / comboboxes
Modern design systems often build their own dropdowns from <button> (the trigger) and <ul> / <div> (the menu). .selectOption() won't work — it's a click-then-click pattern:
// Open the dropdown
await page.getByRole("combobox", { name: "Category" }).click();
// Click the option
await page.getByRole("option", { name: "Electronics" }).click();
// Assert the trigger now shows the selected value
await expect(page.getByRole("combobox", { name: "Category" })).toContainText("Electronics");Many design systems wire the trigger as role="combobox" and the options as role="option". If yours doesn't, fall back to test IDs:
await page.getByTestId("category-trigger").click();
await page.getByTestId("category-option-electronics").click();Either way, the pattern is consistent: click to open, click to choose, assert the result.
Searchable dropdowns / autocomplete
When the dropdown filters as you type (city pickers, user search, tag inputs), combine .fill() with the option-click pattern:
const cityInput = page.getByRole("combobox", { name: "City" });
await cityInput.fill("Lon");
await page.getByRole("option", { name: "London, UK" }).click();
await expect(cityInput).toHaveValue("London, UK");For typeahead suggestions where each keystroke triggers a request, use .pressSequentially() with a small delay so the debounce fires:
await cityInput.pressSequentially("London", { delay: 80 });
await page.getByRole("option", { name: "London, UK" }).click();Aim for 50-100ms — fast enough to keep the test snappy, slow enough that any reasonable debounce timer fires.
Checkboxes
// Toggle on
await page.getByLabel("Remember me").check();
await expect(page.getByLabel("Remember me")).toBeChecked();
// Toggle off
await page.getByLabel("Newsletter").uncheck();
await expect(page.getByLabel("Newsletter")).not.toBeChecked();.check() and .uncheck() are idempotent: if the box is already in the desired state, the call is a no-op (no error, no toggle). This is what makes "ensure terms is checked before submit" tests robust regardless of starting state — you don't need to read the current value first.
For checkbox groups, target each one individually:
await page.getByLabel("TypeScript").check();
await page.getByLabel("Playwright").check();
await page.getByLabel("Cypress").uncheck();For native multi-select-style checkbox groups (rare; usually styled differently per design system), you can also .setChecked(true|false) which is equivalent but reads better when the boolean comes from a variable:
const acceptCookies = userPreferences.cookiesAccepted;
await page.getByLabel("Accept cookies").setChecked(acceptCookies);Radio buttons
A radio group has multiple options; only one can be selected. Each option has its own label:
await page.getByLabel("Standard shipping").check();
await expect(page.getByLabel("Standard shipping")).toBeChecked();
await expect(page.getByLabel("Express shipping")).not.toBeChecked();
await expect(page.getByLabel("Pick up in store")).not.toBeChecked();Treating each radio as its own labelled control is the cleanest pattern. Don't try to "select the third radio"; ask for the one you want by its visible label, the same way a user would.
Toggle switches
Toggle switches are usually styled checkboxes — under the hood, they're <input type="checkbox"> elements with custom CSS. Use .check() and .uncheck():
await page.getByLabel("Email notifications").check();
await page.getByLabel("SMS notifications").uncheck();If the toggle is a custom button (not a real input), it'll have role="switch" and aria-checked:
const toggle = page.getByRole("switch", { name: "Dark mode" });
await toggle.click();
await expect(toggle).toHaveAttribute("aria-checked", "true");Date pickers
Native <input type="date"> is the easy case — .fill() with an ISO date:
await page.getByLabel("Delivery date").fill("2026-12-25");
await expect(page.getByLabel("Delivery date")).toHaveValue("2026-12-25");Custom calendar pickers are harder — they're usually click-trigger, then click-day. The pattern:
// Open the calendar
await page.getByLabel("Delivery date").click();
// Navigate to the right month if needed
await page.getByRole("button", { name: "Next month" }).click();
// Click the day
await page.getByRole("button", { name: "December 25, 2026" }).click();
// Verify the input now shows the selected date
await expect(page.getByLabel("Delivery date")).toHaveValue(/2026-12-25/);Every calendar component is a little different. If yours uses test IDs, prefer those (getByTestId('day-2026-12-25')); if it uses semantic roles, getByRole('button', { name: 'December 25, 2026' }) works well because most calendar libraries set the day's accessible name to the full date.
Form-control commands at a glance
Form controls and the Playwright command for each
| HTML pattern | Open / set | Assert | |
|---|---|---|---|
| Native select | <select> | .selectOption('UK') | .toHaveValue('uk') |
| Custom dropdown | <button> + <ul role='listbox'> | click trigger → click option | .toContainText('Electronics') |
| Checkbox / Radio | <input type='checkbox'> / <input type='radio'> | .check() / .uncheck() | .toBeChecked() / .not.toBeChecked() |
| Toggle switch | checkbox styled OR <button role='switch'> | .check() OR .click() | .toBeChecked() OR aria-checked='true' |
| Date input | <input type='date'> OR custom calendar | .fill('2026-12-25') OR click trigger → click day | .toHaveValue('2026-12-25') |
A complete shipping-form test
Putting every form control type into one realistic test:
import { test, expect } from "@playwright/test";
test.describe("Shipping form", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/checkout/shipping");
});
test("fills every kind of control", async ({ page }) => {
// Text inputs
await page.getByLabel("Full name").fill("Alice Reed");
await page.getByLabel("Address").fill("221B Baker Street");
// Native select
await page.getByLabel("Country").selectOption("United Kingdom");
// Custom combobox (city autocomplete)
const cityInput = page.getByRole("combobox", { name: "City" });
await cityInput.fill("Lon");
await page.getByRole("option", { name: "London" }).click();
await expect(cityInput).toHaveValue("London");
// Date input
await page.getByLabel("Delivery date").fill("2026-12-25");
// Radio group — shipping method
await page.getByLabel("Express shipping (next day)").check();
await expect(page.getByLabel("Standard shipping")).not.toBeChecked();
// Checkboxes — gift options
await page.getByLabel("Gift wrap").check();
await page.getByLabel("Include gift message").check();
// Toggle — save details
await page.getByLabel("Save these details for next time").check();
// Submit
await page.getByRole("button", { name: "Continue to payment" }).click();
await expect(page).toHaveURL(/payment/);
});
test("idempotent .check() — running twice keeps state", async ({ page }) => {
const accept = page.getByLabel("Accept terms");
await accept.check();
await accept.check(); // no-op, no toggle
await expect(accept).toBeChecked();
});
});Read the test for the type of control each line targets. The native select is one line. The autocomplete is fill + click. The radio group asserts negatives explicitly. The toggle is just another .check(). None of the controls need special handling — the underlying HTML decides which command you reach for, and the same locator strategy (getByLabel, getByRole) finds them all.
Coming from Cypress?
The mappings:
cy.get('select').select('UK')→await page.getByLabel('Country').selectOption('UK')cy.get('[type=checkbox]').check()→await page.getByLabel('...').check()cy.get('[type=radio][value=pro]').check()→await page.getByLabel('Pro plan').check()cy.get('[type=date]').type('2026-12-25')→await page.getByLabel('Date').fill('2026-12-25')
The only meaningful difference is the assertion style: Cypress uses .should('be.checked'); Playwright uses await expect(locator).toBeChecked(). Both retry. Both idempotent. The form-control vocabulary is essentially identical.
⚠️ Common mistakes
- Calling
.selectOption()on a custom dropdown. It only works on native<select>elements. If your "Country" dropdown is a styled<button>opening a<div role="listbox">,.selectOption()errors out. Inspect the markup first; if it's not a<select>, use the click-trigger / click-option pattern. - Asserting
.toBeChecked()immediately after.click()on a custom switch. Arole="switch"button isn't a real<input type="checkbox">—.toBeChecked()won't find it. Assert.toHaveAttribute('aria-checked', 'true')instead, or check the visible state another way (a class, a label, an icon). - Targeting radios by value or position instead of label.
page.locator('[type=radio]').nth(2).check()breaks the day someone reorders the options or adds a new one.page.getByLabel('Express shipping').check()survives reorderings, copy changes, and even some i18n drift. Always reach for the label first.
🎯 Practice task
Build a comprehensive form-control spec. 25-30 minutes.
-
The Sauce Demo checkout (
/checkout-step-one.html) only has plain text inputs — for this practice, usehttps://demoqa.com/automation-practice-formwhich has every control type. AddbaseURL: "https://demoqa.com"to your config (or hardcode the URL ingoto). -
Create
tests/form-controls.spec.ts:import { test, expect } from "@playwright/test"; test.describe("Form controls — every type", () => { test.beforeEach(async ({ page }) => { await page.goto("/automation-practice-form"); }); test("fills every control type", async ({ page }) => { // Text inputs await page.getByPlaceholder("First Name").fill("Alice"); await page.getByPlaceholder("Last Name").fill("Reed"); await page.getByPlaceholder("name@example.com").fill("alice@test.com"); await page.getByPlaceholder("Mobile Number").fill("07700900123"); // Radio (gender) await page.getByText("Female", { exact: true }).click(); // Native date picker (Date of Birth — opens a calendar) await page.locator("#dateOfBirthInput").click(); await page.locator(".react-datepicker__month-select").selectOption("0"); // January await page.locator(".react-datepicker__year-select").selectOption("1995"); await page.locator(".react-datepicker__day--015").first().click(); // Multi-select tags (Subjects — autocomplete) const subjects = page.locator("#subjectsInput"); await subjects.fill("Eng"); await page.getByText("English", { exact: true }).click(); // Checkboxes (Hobbies) await page.getByText("Sports", { exact: true }).click(); await page.getByText("Music", { exact: true }).click(); // Address textarea await page.getByPlaceholder("Current Address").fill("221B Baker Street"); }); test("idempotent radio — already checked is a no-op", async ({ page }) => { await page.getByText("Female", { exact: true }).click(); await page.getByText("Female", { exact: true }).click(); // Still checked once, not toggled off (radios don't untoggle) await expect(page.locator("#gender-radio-2")).toBeChecked(); }); }); -
Run the spec across all three browsers. (Be aware the demoqa site has ads that occasionally interfere — if a click is blocked, try
--headedto see what's overlapping; the lesson on actionability fixes this kind of issue.) -
Try
.selectOption()on a custom dropdown to see it fail. Add a test that runsawait page.locator('#state').selectOption('NCR')against demoqa's State dropdown — which is a custom React Select, not a native<select>. Watch Playwright reportElement is not a <select> element. That's the framework being explicit. Switch to the click-trigger pattern:await page.locator('#state').click(); await page.getByText('NCR', { exact: true }).click(). -
Stretch: add a test that targets the State dropdown using
.or()so the test passes whether the underlying component is a native<select>or a custom React widget (you can test this by writing the locator as both forms joined with.or()). This is the migration-resilient pattern: write tests that survive the day a developer rewrites a form control.
You now have a vocabulary for every kind of form Playwright will ever face. That closes chapter 2 — locators and actions. The next chapter steps up to page-level concerns: navigation between pages, waiting strategies (auto-wait vs explicit), new tabs and popups, and iframes. The patterns there assume the locator-and-action fluency you've built here.