Handling Dropdowns, Checkboxes, and Radio Buttons

8 min read

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

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. A role="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.

  1. The Sauce Demo checkout (/checkout-step-one.html) only has plain text inputs — for this practice, use https://demoqa.com/automation-practice-form which has every control type. Add baseURL: "https://demoqa.com" to your config (or hardcode the URL in goto).

  2. 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();
      });
    });
  3. Run the spec across all three browsers. (Be aware the demoqa site has ads that occasionally interfere — if a click is blocked, try --headed to see what's overlapping; the lesson on actionability fixes this kind of issue.)

  4. Try .selectOption() on a custom dropdown to see it fail. Add a test that runs await page.locator('#state').selectOption('NCR') against demoqa's State dropdown — which is a custom React Select, not a native <select>. Watch Playwright report Element 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().

  5. 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.

// tip to track lessons you complete and pick up where you left off across devices.