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.
“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
Combobox Multiselect Interaction Flow
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
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.
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.
Basic Click Selection and Chip Rendering
StraightforwardGoal
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
What to Assert Beyond the UI
- The hidden form input (if present) contains both selected values
- The
aria-selectedattribute on each option reflects the selection state - The
onChangehandler 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);
});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.
Chip Removal via Click and Backspace
ModerateGoal
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
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.
Async Option Loading with Debounced Search
ComplexGoal
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
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();
});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.
Virtualized Dropdown with 10,000+ Options
ComplexGoal
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
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-activedescendanteven 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.
ARIA Attributes and Screen Reader Announcements
ModerateGoal
Verify that all required ARIA attributes are present, correctly valued, and update dynamically as the user interacts with the combobox.
Playwright Implementation
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
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.
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
How to Test Airtable Form Embed
Step-by-step guide to testing embedded Airtable forms with Playwright. Covers iframe...
How to Test Copy Button
Step-by-step guide to testing code block copy buttons with Playwright. Clipboard API...
How to Test Cmd+K Command Palette
A practical guide to testing Cmd+K command palettes with Playwright. Covers fuzzy search...
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.