|

Testing Multi-Select Dropdowns in Playwright: The Complete Guide

Multi-select dropdowns look harmless until you write your first test against one and discover there are at least four totally different widgets hiding behind that label. A native <select multiple>, a React combobox, a checkbox-list popover, and a tag-input field each respond to completely different actions, and using the wrong one gives you a green test that selected nothing. This guide to Playwright multi-select dropdown testing in TypeScript walks through every common variant, the exact API that drives each one, and how to assert the selection reliably.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

Why multi-select dropdowns trip up automation

The word “dropdown” hides an enormous range of implementations. The only one that behaves like a real form control is the native HTML <select multiple>, and almost no design system ships it because it cannot be styled. What you actually meet in modern apps is a custom widget: a button that opens a popover full of options, where each click toggles a chip, a checkbox, or an aria-selected attribute.

That distinction decides everything. Native selects are driven by selectOption, which talks directly to the control’s value. Custom widgets ignore selectOption entirely and must be driven by opening the menu and clicking option elements one at a time. Picking the wrong technique is the single most common reason a multi-select test passes without selecting anything, so the first job is always to identify which kind you are looking at.

Native <select multiple> with selectOption

When the markup is a genuine <select multiple>, Playwright’s locator.selectOption() is the right tool and it accepts an array. It clears any prior selection, selects exactly the values you pass, and waits for the element to be actionable first. You can target options by visible label, by value, by index, or by a raw string that matches the value.

import { test, expect } from '@playwright/test';

test('select multiple options on a native select', async ({ page }) => {
  await page.goto('https://example.com/native-multiselect');

  const fruits = page.getByLabel('Favourite fruits');

  // Pass an array to select more than one option at once
  await fruits.selectOption(['apple', 'cherry', 'mango']);

  // Or select by visible label / index / value object
  await fruits.selectOption([
    { label: 'Apple' },
    { value: 'cherry' },
    { index: 4 },
  ]);

  // Assert the resolved values directly on the control
  await expect(fruits).toHaveValues([/apple/, /cherry/, /mango/]);
});

The key assertion is toHaveValues(), a web-first matcher built specifically for multi-select controls. It auto-retries until the element’s selected values match the array of strings or regular expressions you pass, so you never need to read .value manually. Remember that each call to selectOption replaces the entire selection rather than adding to it, which is exactly how the browser treats Ctrl-clicking a fresh set of options.

Custom dropdowns: open, click, assert

For React, Vue, and design-system components there is no real <select>, so selectOption will throw “Element is not a select element.” The reliable recipe is: click the trigger to open the popover, click each option by its accessible name, then close the menu and assert on the rendered chips or the option’s selected state. Scope the option locators to the open listbox so you never accidentally match text elsewhere on the page.

import { test, expect } from '@playwright/test';

test('select multiple options on a custom combobox', async ({ page }) => {
  await page.goto('https://example.com/react-multiselect');

  // 1. Open the popover
  await page.getByRole('combobox', { name: 'Skills' }).click();

  // 2. Scope to the open listbox so option names stay unambiguous
  const listbox = page.getByRole('listbox');
  for (const skill of ['Playwright', 'TypeScript', 'CI/CD']) {
    await listbox.getByRole('option', { name: skill }).click();
  }

  // 3. Dismiss the menu (Escape is the most reliable close)
  await page.keyboard.press('Escape');

  // 4. Assert on the rendered chips, not the click itself
  const tags = page.getByTestId('selected-tags');
  await expect(tags.getByRole('listitem')).toHaveText([
    'Playwright',
    'TypeScript',
    'CI/CD',
  ]);
});

Two details make this robust. First, asserting with an array on toHaveText() checks both the count and the exact contents of the selected chips in order, catching “selected too many” and “selected the wrong one” in a single line. Second, scoping option clicks to getByRole('listbox') prevents the classic flake where the same label text appears in both the menu and the chip area, which would otherwise make Playwright’s strict mode throw on an ambiguous match.

Handling checkbox-style option lists

A common variant renders each option as a checkbox inside the popover. Here you should assert on the checkbox state rather than chips, because the source of truth is aria-checked. Use getByRole with the appropriate role and toBeChecked() to verify the selection survived closing and reopening the menu.

import { test, expect } from '@playwright/test';

test('checkbox-list multi-select keeps state', async ({ page }) => {
  await page.goto('https://example.com/checkbox-multiselect');

  const trigger = page.getByRole('button', { name: 'Filter by country' });
  await trigger.click();

  const menu = page.getByRole('menu');
  await menu.getByRole('menuitemcheckbox', { name: 'India' }).click();
  await menu.getByRole('menuitemcheckbox', { name: 'Brazil' }).click();

  // The selected items should report aria-checked="true"
  await expect(menu.getByRole('menuitemcheckbox', { name: 'India' })).toBeChecked();
  await expect(menu.getByRole('menuitemcheckbox', { name: 'Brazil' })).toBeChecked();

  // Reopen and confirm the choices persisted
  await page.keyboard.press('Escape');
  await trigger.click();
  await expect(menu.getByRole('menuitemcheckbox', { name: 'India' })).toBeChecked();
});

Reopening the menu and re-asserting is a deliberately strong check. Plenty of multi-select bugs only show up on the second open, when local state and the visible UI have drifted apart, so a test that toggles, closes, and reopens catches a whole class of state-management regressions that a single-open test misses.

Searchable (typeahead) multi-selects

Many real components filter the option list as you type, which is great for users and awkward for tests because the option you want may not exist in the DOM until you type. The pattern is to fill the search input, wait for the filtered option to appear, click it, and repeat. Clearing the input between selections keeps each search deterministic.

import { test, expect, Page } from '@playwright/test';

async function pickFromTypeahead(page: Page, label: string, value: string) {
  const input = page.getByRole('combobox', { name: label });
  await input.click();
  await input.fill(value);

  // Wait for the async-filtered option, then select it
  const option = page.getByRole('option', { name: value, exact: true });
  await option.click();

  // Clear so the next search starts clean
  await input.fill('');
}

test('searchable multi-select adds several tags', async ({ page }) => {
  await page.goto('https://example.com/async-multiselect');

  for (const lang of ['Rust', 'Go', 'Kotlin']) {
    await pickFromTypeahead(page, 'Languages', lang);
  }

  const chips = page.getByTestId('chips').getByRole('button');
  await expect(chips).toHaveText(['Rust', 'Go', 'Kotlin']);
});

Notice there is no manual waitForTimeout. Clicking the option locator auto-waits for that specific filtered result to render, so even if the option list is populated from a debounced network call, Playwright blocks until the named option is attached, visible, and stable. If the backend is slow, raise the action timeout rather than sprinkling fixed sleeps, which only make the suite slower and flakier.

Deselecting and clearing selections

Selecting is only half the contract. Most multi-selects let users remove a single value by clicking its chip’s “x” button, or clear everything via a dedicated control. Test removal explicitly, because off-by-one bugs in chip removal are extremely common and never surface if you only ever add.

import { test, expect } from '@playwright/test';

test('remove one chip and clear the rest', async ({ page }) => {
  await page.goto('https://example.com/react-multiselect');
  await page.getByRole('combobox', { name: 'Skills' }).click();
  const listbox = page.getByRole('listbox');
  for (const s of ['Playwright', 'TypeScript', 'CI/CD']) {
    await listbox.getByRole('option', { name: s }).click();
  }
  await page.keyboard.press('Escape');

  // Remove a single value via its chip remove button
  await page
    .getByRole('listitem')
    .filter({ hasText: 'TypeScript' })
    .getByRole('button', { name: /remove/i })
    .click();

  await expect(page.getByRole('listitem')).toHaveText(['Playwright', 'CI/CD']);

  // Clear all remaining selections
  await page.getByRole('button', { name: 'Clear all' }).click();
  await expect(page.getByRole('listitem')).toHaveCount(0);
});

For the native <select multiple>, clearing is simpler: call selectOption([]) with an empty array to deselect everything in one shot, then assert with await expect(select).toHaveValues([]). That symmetry between add and clear is worth covering in its own test so a refactor that breaks deselection cannot ship silently.

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Choosing the right API for each widget

Match the technique to the actual implementation, which you can usually tell by inspecting the DOM for a <select> tag versus a role="listbox" popover. This table maps the common cases.

Widget typeHow to selectHow to assert
Native <select multiple>selectOption([...])toHaveValues([...])
Custom combobox with chipsOpen trigger, click each optiontoHaveText([...]) on chips
Checkbox / menuitemcheckbox listClick each checkbox optiontoBeChecked() per option
Searchable / typeaheadfill() then click filtered optiontoHaveText([...]) on chips
Tag input (free text)fill() + Enter per tagtoHaveCount() on tags

Making multi-select tests stable

Custom dropdowns are some of the flakiest components to automate because of portals, animations, and ambiguous text. A handful of habits eliminate most of the noise.

  • Scope to the open menu. Always chain option clicks off getByRole('listbox') or getByRole('menu') so portal-rendered menus do not collide with chip text under strict mode.
  • Assert with arrays. toHaveText([...]) and toHaveValues([...]) verify count, contents, and order in one auto-retrying matcher.
  • Close the menu before asserting on chips. Some libraries only commit the selection on close, so press Escape first.
  • Group independent checks with expect.soft. When validating many chips at once, soft assertions report every mismatch in one run instead of stopping at the first failure.

Soft assertions are especially handy for a wide selection state where you want the full picture in one go rather than fixing failures one at a time.

import { test, expect } from '@playwright/test';

test('verify the whole selection state at once', async ({ page }) => {
  await page.goto('https://example.com/react-multiselect');
  await page.getByRole('combobox', { name: 'Skills' }).click();
  const listbox = page.getByRole('listbox');
  for (const s of ['Playwright', 'TypeScript']) {
    await listbox.getByRole('option', { name: s }).click();
  }
  await page.keyboard.press('Escape');

  const chips = page.getByRole('listitem');
  // Soft assertions collect every failure instead of stopping at the first
  await expect.soft(chips).toHaveCount(2);
  await expect.soft(chips.nth(0)).toHaveText('Playwright');
  await expect.soft(chips.nth(1)).toHaveText('TypeScript');
});

Solid Playwright multi-select dropdown testing comes down to one decision made early: is this a native control or a custom widget? Once you know that, the right API follows immediately, your assertions anchor on what the user actually sees selected, and the whole class of “green test that selected nothing” failures disappears. Identify the widget, drive it the way a user would, and assert on the rendered selection every time.

FAQ

Why does selectOption throw “Element is not a select element”?

Because selectOption only works on a native HTML <select>. React, Vue, and design-system multi-selects are custom popovers built from buttons and role="listbox" markup, not real select elements. For those, click the trigger to open the menu and click each option individually with getByRole('option', { name }), scoped to the open listbox.

How do I assert that multiple options are selected in Playwright?

For a native <select multiple> use await expect(select).toHaveValues([...]), which auto-retries until the selected values match. For custom widgets, assert on the rendered chips with an array, like await expect(page.getByRole('listitem')).toHaveText(['A', 'B']), since that checks the count, contents, and order of the selection at once.

How do I select an option that only appears after typing in a searchable dropdown?

Click the combobox, call fill() with the search text, then click the filtered option locator. Playwright auto-waits for that specific option to attach and become visible, so you do not need a fixed waitForTimeout even when options come from a debounced network request. Clear the input with fill('') between picks to keep each search deterministic.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.