Component Testing Guide

How to Test Combobox Multiselect with Playwright: Headless UI, Radix, Keyboard Nav, and Virtualized Dropdowns

A scenario-by-scenario walkthrough of testing combobox multiselect components with Playwright. Covers Headless UI and Radix patterns, arrow key navigation, Enter and Escape handling, chip removal, async option loading, and virtualized dropdown lists that only render visible items.

87%

According to the WebAIM Million report, 87% of the top one million homepages had detectable WCAG failures, and combobox widgets are among the most commonly broken interactive patterns for keyboard and screen reader users.

WebAIM Million 2024

0Keyboard shortcuts to test
0Scenarios covered
0UI libraries addressed
0%Fewer lines with Assrt

Combobox Multiselect Interaction Flow

UserInput FieldListbox (Dropdown)Chip ContainerServer APIFocus input / type queryFetch matching options (async)Return filtered resultsDisplay dropdown optionsArrow down + Enter to selectAdd chip for selected itemClick X on chip to removeUpdate value, refocus input

1. Why Testing Combobox Multiselect Is Harder Than It Looks

A combobox multiselect looks simple on the surface: type to filter, click to select, chips appear. But under the hood, libraries like Headless UI, Radix UI, and Downshift implement the WAI-ARIA combobox pattern with dozens of interacting states. The input has role="combobox", the dropdown has role="listbox", each option has role="option" with aria-selected toggling on highlight and selection. The input tracks aria-activedescendant to tell assistive technology which option is focused, even though DOM focus never leaves the input element. Testing frameworks that rely on visible focus indicators miss this entirely.

The complexity multiplies when you add real-world requirements. Keyboard navigation must handle ArrowDown, ArrowUp, Enter, Escape, Home, End, and Backspace (to delete the last chip). Async option loading introduces debounce timers, loading spinners, empty states, and network error recovery. Virtualized dropdowns (using libraries like react-virtual or @tanstack/react-virtual) only render the visible slice of options, so scrolling to a specific item requires triggering the virtualizer to render it first. Each of these layers interacts with the others in ways that produce subtle, hard-to-reproduce failures.

There are five structural reasons this component is hard to test reliably. First, the ARIA state model is complex: the highlighted option, the selected options, the input value, and the dropdown visibility are four independent pieces of state that must stay synchronized. Second, Headless UI and Radix use portals to render the dropdown outside the component tree, so your locator scope matters. Third, keyboard events must fire on the correct element (the input, not the listbox) because the combobox pattern routes keyboard events through the input. Fourth, virtualized lists do not render off-screen options in the DOM, so getByRole('option') only finds what is currently visible. Fifth, different libraries implement the same WAI-ARIA pattern with different DOM structures, class names, and data attributes.

Combobox Multiselect State Machine

🌐

Idle

Input empty, dropdown closed

Focused

Input focused, dropdown may open

⚙️

Filtering

User types, options filtered

↪️

Highlighting

Arrow keys move highlight

Selecting

Enter adds chip

💳

Chip Added

Input clears, dropdown updates

Keyboard Navigation Event Routing

🌐

Keydown on Input

Event fires on combobox input

⚙️

Library Handler

Headless UI / Radix intercepts

↪️

State Update

activeDescendant changes

DOM Update

aria-selected, scroll position

A good combobox multiselect test suite covers all of these surfaces. The sections below walk through each scenario with runnable Playwright TypeScript you can copy directly into your project.

2. Setting Up Your Test Environment

Before writing scenarios, configure your test environment for combobox-specific challenges. The main concerns are: waiting for async option loading, handling portaled dropdowns that render outside the component root, and mocking API endpoints for controlled filtering behavior.

Combobox Test Environment Checklist

  • Set up a Playwright project with a component testing page or Storybook instance
  • Configure actionTimeout to at least 5000ms for debounced inputs
  • Prepare mock API routes for async option endpoints
  • Create a fixture with at least 20 options (enough to trigger scrolling)
  • Create a fixture with 10,000+ options for virtualization tests
  • Ensure the test page renders the combobox with visible labels for getByRole
  • Disable CSS transitions in test mode to eliminate timing flakes
  • Pin library versions (Headless UI, Radix) to avoid DOM structure drift

Playwright Configuration

playwright.config.ts

Mock API for Async Options

Most production combobox components fetch options from an API endpoint on each keystroke, typically debounced by 200 to 300 milliseconds. In tests, intercept these requests with Playwright route handlers so you control the timing, the response data, and error scenarios without depending on a real backend.

test/helpers/combobox-mocks.ts
Install Dependencies

3. Scenario: Basic Click Selection and Chip Rendering

The most fundamental scenario is clicking options in the dropdown and verifying that chips appear for each selection. This sounds simple, but the dropdown may render in a portal (a DOM node outside your component tree), the chip container updates asynchronously, and the selected option may need to disappear from the dropdown or show a checkmark indicator. Your test must verify all three behaviors.

1

Basic Click Selection and Chip Rendering

Straightforward

Goal

Open the combobox dropdown by clicking the input, select two options by clicking them, and verify that two chips render in the chip container with the correct labels.

Preconditions

  • Combobox component rendered with at least 5 static options
  • No options pre-selected
  • Dropdown closed on initial render

Playwright Implementation

combobox-basic.spec.ts

What to Assert Beyond the UI

  • The hidden form input (if present) contains both selected values
  • The aria-selected attribute on each option reflects the selection state
  • The onChange handler received an array with both values (verify via a data attribute or console log intercept)

Basic selection: Playwright vs Assrt

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

test('select two options', async ({ page }) => {
  await page.goto('/combobox-demo');
  const input = page.getByRole('combobox');
  await input.click();
  const listbox = page.getByRole('listbox');
  await listbox.getByRole('option', { name: 'Apple' }).click();
  await input.click();
  await listbox.getByRole('option', { name: 'Banana' }).click();
  const chips = page.locator('[data-testid="chip"]');
  await expect(chips).toHaveCount(2);
});
17% fewer lines

4. Scenario: Full Keyboard Navigation (Arrow Keys, Enter, Escape)

Keyboard navigation is where combobox tests most often fail. The WAI-ARIA combobox pattern requires that ArrowDown moves the visual highlight to the next option, ArrowUp moves it to the previous option, Enter selects the currently highlighted option, and Escape closes the dropdown without selecting. The tricky part: DOM focus stays on the input element the entire time. The “focused” option is indicated only by the aria-activedescendant attribute on the input and a visual highlight style on the option. Playwright's press method fires keyboard events on the focused element, which is correct for this pattern, but you must assert on ARIA attributes rather than CSS focus states.

2

Full Keyboard Navigation

Complex

Goal

Using only the keyboard, open the dropdown, navigate to the third option, select it with Enter, verify the chip, then reopen and press Escape to close without selecting.

Preconditions

  • Combobox with at least 5 options
  • No options pre-selected
  • Component follows WAI-ARIA combobox pattern

Playwright Implementation

combobox-keyboard.spec.ts

What to Assert Beyond the UI

  • aria-activedescendant on the input matches the ID of the visually highlighted option after every ArrowDown/ArrowUp
  • The aria-expanded attribute on the input is "true" when the dropdown is open and "false" after Escape
  • No duplicate selections occur when pressing Enter rapidly

Keyboard nav: Playwright vs Assrt

test('keyboard select third option', async ({ page }) => {
  await page.goto('/combobox-demo');
  const input = page.getByRole('combobox');
  await input.focus();
  await input.press('ArrowDown');
  await input.press('ArrowDown');
  await input.press('ArrowDown');
  await input.press('Enter');
  const chips = page.locator('[data-testid="chip"]');
  await expect(chips).toHaveCount(1);
});
27% fewer lines

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started

5. Scenario: Chip Removal via Click and Backspace

Once options are selected as chips, users need to remove them. There are two common removal patterns. The first is clicking a close button (usually an X icon) on the chip itself. The second is pressing Backspace in the input when the input is empty, which removes the last chip. Both patterns must update the form value, re-enable the option in the dropdown, and maintain correct ARIA state. A common bug is that the removed option does not reappear in the dropdown, or the chip count in an aria-label on the container does not update.

3

Chip Removal via Click and Backspace

Moderate

Goal

Select three options, remove one by clicking its close button, remove another by pressing Backspace, and verify the dropdown state and form value update correctly.

Playwright Implementation

combobox-chip-removal.spec.ts

What to Assert Beyond the UI

  • The underlying form value (hidden input or state) no longer contains the removed option
  • The dropdown option for the removed chip resets to aria-selected="false"
  • Backspace on empty input removes the last chip, not the first
  • Backspace when input has text deletes a character instead of removing a chip

6. Scenario: Async Option Loading with Debounced Search

Production combobox components rarely ship with all options loaded upfront. Instead, they fetch matching options from an API on each keystroke, typically debounced by 200 to 300 milliseconds. This creates three additional states your tests must handle: the loading state (spinner or skeleton), the empty state (no results found), and the error state (network failure). Each state renders different DOM elements, and the timing of the debounce means your test must wait for the correct state to resolve before making assertions.

4

Async Option Loading with Debounced Search

Complex

Goal

Type a search query, verify the loading indicator appears, wait for results to load, select an option, then test empty and error states by manipulating the mock API.

Playwright Implementation

combobox-async.spec.ts

What to Assert Beyond the UI

  • The network request includes the correct query parameter after debounce (not on every keystroke)
  • Only one API request fires per debounce window, even if the user types multiple characters rapidly
  • The loading indicator disappears once the response resolves

Async loading: Playwright vs Assrt

test('async search and select', async ({ page }) => {
  await mockOptionsEndpoint(page);
  await page.goto('/combobox-demo');
  const input = page.getByRole('combobox');
  await input.fill('app');
  const listbox = page.getByRole('listbox');
  await expect(listbox).toBeVisible({ timeout: 3000 });
  await listbox.getByRole('option', { name: 'Apple' }).click();
  await expect(
    page.locator('[data-testid="chip"]').getByText('Apple')
  ).toBeVisible();
});
27% fewer lines

7. Scenario: Virtualized Dropdown with 10,000+ Options

When a combobox has thousands of options, rendering all of them as DOM nodes would destroy performance. Libraries like @tanstack/react-virtual and react-windowsolve this by only rendering the options visible in the scroll viewport, typically 10 to 20 items out of thousands. This creates a testing problem: Playwright's getByRole('option') only finds options that exist in the DOM. An option at index 5000 simply does not exist in the DOM until the user scrolls to it or types a filter query that brings it into the visible range.

The correct testing strategy for virtualized dropdowns is to use the search/filter functionality to narrow the list, which brings the target option into the rendered range. Do not attempt to programmatically scroll the virtualizer to a specific index; that couples your test to the virtualization library's internal API. Instead, type enough characters to filter the list down to a manageable size, then interact with the visible options.

5

Virtualized Dropdown with 10,000+ Options

Complex

Goal

Render a combobox with 10,000 options, verify that the DOM only contains a subset, use type-to-filter to locate a specific option, select it, and confirm performance is acceptable.

Playwright Implementation

combobox-virtualized.spec.ts

What to Assert Beyond the UI

  • The DOM node count stays below a reasonable threshold (under 50 rendered options for a 10,000 item list)
  • Keyboard navigation updates aria-activedescendant even when the virtualizer re-renders the visible slice
  • No layout shift or jank when scrolling rapidly through the virtualized list

8. Scenario: ARIA Attributes and Screen Reader Announcements

A combobox multiselect that passes visual testing but fails ARIA compliance is broken for the 15% of users who rely on assistive technology. The WAI-ARIA combobox pattern requires specific attributes on specific elements, and missing or incorrect values will cause screen readers to misrepresent the component. Playwright is excellent for asserting ARIA attributes because toHaveAttribute reads the actual DOM attribute, not the visual rendering.

6

ARIA Attributes and Screen Reader Announcements

Moderate

Goal

Verify that all required ARIA attributes are present, correctly valued, and update dynamically as the user interacts with the combobox.

Playwright Implementation

combobox-aria.spec.ts

9. Common Pitfalls That Break Combobox Multiselect Tests

Portal-Mounted Dropdowns

Both Headless UI and Radix render the listbox dropdown in a portal element appended to document.body, not inside the combobox component tree. If your test scopes locators to a parent container (for example, page.locator('.combobox-wrapper').getByRole('listbox')), it will never find the dropdown. Always scope listbox queries to page.getByRole('listbox') at the page level when working with portaled components.

Debounce Timing in Fast Tests

Playwright executes actions faster than any human user. If the combobox debounces input by 300ms, Playwright can type a full query and try to click an option before the debounce fires and the dropdown renders. The fix is to await expect(listbox).toBeVisible() after typing, which Playwright will auto-retry until the debounce resolves and the listbox appears. Never use a hard page.waitForTimeout(300) because it is fragile and slow. Playwright's auto-retry is both faster and more reliable.

Focus Management After Selection

After selecting an option, some implementations close the dropdown and blur the input, while others keep the dropdown open and focus stays on the input for continued selection. Your test must match the actual implementation. If your component keeps the dropdown open after selection, do not add an input.click()before the next selection (it is already open). If it closes, you need to reopen it. Check your component's closeOnSelect or closeOnChange prop to know which behavior to expect.

Stale References After Virtualization Re-render

In virtualized lists, scrolling causes the virtualizer to unmount and remount option elements. If your test stores a reference to an option element and then scrolls, that reference becomes stale. Always re-query for elements after any scroll or filter action. Playwright locators are lazy by design (they re-query on each action), so this is usually not an issue if you use locators rather than element handles. Avoid elementHandle = await locator.elementHandle() in virtualized combobox tests.

Keyboard Events on the Wrong Element

The WAI-ARIA combobox pattern routes all keyboard events through the input element, not the listbox. If your test calls listbox.press('ArrowDown') instead of input.press('ArrowDown'), the event fires on the wrong element and nothing happens. This is the most common keyboard navigation test bug. Always press keys on the combobox input, even when the visual highlight appears to be “inside” the listbox.

Combobox Test Anti-Patterns to Avoid

  • Scoping listbox locators inside a parent container (portals render at body level)
  • Using waitForTimeout instead of auto-retrying assertions for debounced inputs
  • Pressing keyboard events on the listbox instead of the combobox input
  • Storing element handles in virtualized lists (they go stale on scroll)
  • Assuming the dropdown closes after selection (check closeOnSelect prop)
  • Testing with hardcoded CSS class selectors that differ between Headless UI and Radix
  • Skipping ARIA attribute assertions (visual correctness does not mean accessibility)
  • Forgetting to test empty state and error state for async comboboxes
Combobox Multiselect Test Suite Run

10. Writing These Scenarios in Plain English with Assrt

Every scenario above ranges from 15 to 50 lines of Playwright TypeScript. The keyboard navigation test alone has three separate test blocks with intricate aria-activedescendant assertions. When your combobox library updates from Headless UI v1 to v2 (which changed the option rendering structure), or when you switch from Radix to Headless UI, every selector in every test breaks simultaneously. Assrt lets you describe the interaction intent in plain English, compiles it to Playwright TypeScript, and regenerates the selectors automatically when the underlying DOM changes.

The keyboard navigation scenario from Section 4 shows the value most clearly. In raw Playwright, you need to know the exact ARIA attribute names, the option ID format, and the keyboard event routing rules. In Assrt, you describe what the user does and what should happen. The framework handles the ARIA plumbing.

scenarios/combobox-full-suite.assrt

Assrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections. The generated tests are committed to your repo as real test files you can read, run, debug, and modify. When your combobox library updates its DOM structure (Headless UI v2 changed option rendering, Radix moved to a different portal strategy), Assrt detects the selector failures, analyzes the new DOM, and opens a pull request with updated locators. Your scenario files stay untouched.

Start with the basic click selection scenario. Once it is green in CI, add the keyboard navigation test. Then add async loading, then virtualization, then ARIA compliance. In a single afternoon you can build comprehensive combobox multiselect coverage that catches regressions across library upgrades, accessibility audits, and performance changes.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk