May 22, 2026
How to Test Custom Select Dropdowns in Modern Frontend Apps
A practical guide to test custom select dropdowns in modern frontend apps with Playwright, covering keyboard support, ARIA behavior, and regression-safe assertions.
Custom select components solve real UI problems, but they also create real testing problems. Once you move beyond the native <select>, you inherit keyboard handling, focus management, ARIA semantics, portal rendering, animation timing, search behavior, and a lot of brittle DOM structure. That is why teams often end up with tests that click a button, pick an option, and then break the next time the component gets a refactor.
If you need to test custom select dropdowns reliably, the goal is not just to verify that a value changes. You want to check that the component behaves like an accessible control, survives refactors, and still works through the keyboard, mouse, and assistive technology expectations your users depend on.
This guide focuses on practical frontend testing patterns for custom select widgets in modern apps, with examples in Playwright. The same principles apply whether you use Cypress, Selenium, or a lower-level component test runner. The details differ, but the testing strategy is the same: assert behavior, not implementation noise.
Why custom selects are harder than native selects
Native HTML select elements bring a lot of testing value for free. Browsers implement keyboard interactions, option semantics, focus behavior, form submission, and accessibility mappings. When you replace that with a styled button, listbox, combobox, or tree of divs, you take responsibility for all of it.
Typical custom select features include:
- An input or button that opens a popup
- A list of options rendered in-place or in a portal
- Search or typeahead filtering
- Single-select or multi-select behavior
- Disabled states and async loading states
- Controlled value updates from framework state
- Clear buttons, chips, and grouped options
Each of those features can fail in a different way. A component can look correct in the browser while still being impossible to use with a keyboard, or it can work manually but fail under automated testing because the options render in a portal or disappear before your assertion runs.
A custom select should be tested like an interaction model, not like a static DOM fragment.
Start by identifying the control type
Before you write tests, identify what kind of custom select you are dealing with. The ARIA pattern matters because the interaction rules differ.
Common patterns include:
- Combobox: an editable field with autocomplete behavior, often used for searchable selects
- Listbox: a button or input that opens a list of options, often not editable
- Menu button: a trigger opens a menu, sometimes used incorrectly as a select replacement
- Multi-select listbox: users can choose more than one option
The WAI-ARIA Authoring Practices are the best reference for expected keyboard behavior. In testing, this matters because the assertions should match the intended pattern. For example, a combobox usually supports text entry, ArrowDown navigation, and Escape to close. A listbox may rely more on arrow keys and Enter or Space.
If your component claims accessibility support, test it against the expected ARIA role, accessible name, and keyboard model. If it does not expose the right semantics, you may still be able to test it, but you should treat that as a product issue, not just a test inconvenience.
What to test, at minimum
A good baseline for test custom select dropdowns includes five categories.
1. Opening and closing behavior
Verify the popup opens on the expected trigger action and closes through the expected user paths:
- Click trigger opens list
- Escape closes list
- Clicking outside closes list, if that is intended
- Tab moves focus away in a predictable way
2. Selection behavior
Check that the selected value updates correctly:
- Mouse selection works
- Keyboard selection works
- Selected item is reflected in the trigger text or field value
- The underlying form value changes if the component is inside a form
3. Accessibility behavior
Check the semantics that automated tools and assistive tech rely on:
- Trigger has an accessible name
- Popup is associated with the trigger
- Selected state is exposed where appropriate
- Focus stays visible and predictable
- Disabled items are not selectable
4. Edge cases
Make sure you cover the things that usually break during refactors:
- Long option labels
- Duplicate labels with different underlying values
- Empty state and no-results state
- Keyboard navigation at the first and last option
- Async option loading
- Portal-based rendering
5. Regression signals
Assertions should focus on stable outcomes, not implementation details:
- Selected label or value changes
- Popup visibility changes
aria-expanded,aria-selected, andaria-activedescendantstate changes- Form submission carries the right value
Prefer role-based locators over CSS selectors
One of the easiest ways to make select component testing less brittle is to locate elements by role and accessible name. This aligns your tests with user-facing semantics rather than component internals.
Here is a simple Playwright example for a button-based custom select:
import { test, expect } from '@playwright/test';
test('selects an option from a custom dropdown', async ({ page }) => {
await page.goto('/settings');
const countrySelect = page.getByRole(‘button’, { name: /country/i }); await countrySelect.click();
await page.getByRole(‘option’, { name: ‘Canada’ }).click();
await expect(countrySelect).toHaveText(‘Canada’); });
This is more readable than targeting a .dropdown > .trigger class and less likely to fail when the UI is restyled.
For search-based selects, the trigger may be an input with role="combobox":
typescript
const cityCombobox = page.getByRole('combobox', { name: /city/i });
await cityCombobox.fill('ber');
await page.getByRole('option', { name: 'Berlin' }).click();
await expect(cityCombobox).toHaveValue('Berlin');
If your component does not expose roles correctly, that is often the first thing to fix. Testing can reveal an accessibility defect before a screen reader user does.
Test keyboard support explicitly
A lot of custom dropdown bugs only show up with the keyboard. Mouse-only testing misses them.
The keyboard matrix depends on the component type, but a strong baseline usually includes:
Enteropens the dropdown or activates the selected itemSpaceopens the dropdown for non-editable triggersArrowDownandArrowUpmove through optionsHomeandEndjump to boundaries when supportedEscapecloses without selecting a new optionTableaves the control in a sane state
A simple Playwright test for keyboard navigation might look like this:
import { test, expect } from '@playwright/test';
test('navigates options with the keyboard', async ({ page }) => {
await page.goto('/profile');
const trigger = page.getByRole(‘button’, { name: /timezone/i }); await trigger.focus(); await page.keyboard.press(‘Enter’); await page.keyboard.press(‘ArrowDown’); await page.keyboard.press(‘ArrowDown’); await page.keyboard.press(‘Enter’);
await expect(trigger).toHaveText(‘UTC+02:00’); });
This test is useful because it verifies the full interaction path, not just a final selected value. If the component mismanages focus, the keyboard sequence will fail before your assertion.
Useful keyboard assertions
When testing custom select dropdowns, consider checking these states:
- The active option changes as arrows move
- Focus remains trapped or released according to spec
- The highlighted item is visible when navigating
aria-expandedtoggles as the popup opens and closes- The component does not accidentally submit a form on Space or Enter, unless that is the expected behavior
For advanced widgets, it can be worth reading the accessibility tree in DevTools or using Playwright selectors that reflect roles rather than text nodes alone.
Assert ARIA state, not just visual text
Many test suites stop once the visible text changes. That is not enough for accessible dropdown testing.
A custom select can show the right label while exposing broken ARIA state. For example, the control may visually update but leave aria-expanded stuck on true, or it may fail to mark the active option correctly. These bugs can create confusing screen reader behavior even if visual users never notice.
Useful assertions include:
typescript
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByRole('listbox')).toBeVisible();
await expect(page.getByRole('option', { name: 'Canada' })).toHaveAttribute('aria-selected', 'true');
If your component uses aria-activedescendant, verify that it changes as expected during keyboard navigation. That often catches focus management regressions in combobox implementations.
If your select is visually perfect but semantically wrong, your test suite should fail anyway.
Handle portals and overlays carefully
Many modern UI libraries render dropdown menus in a portal attached to document.body. This is common because it avoids clipping and z-index problems, but it changes how you write tests.
Problems you may see:
- The popup is not inside the trigger container
- The dropdown appears after a small animation delay
- The option list is detached from the component subtree
- Clicking outside closes the menu before your test finds the option
The answer is usually not longer sleeps. Instead, use explicit assertions that wait for the popup to be visible and ready.
typescript
await trigger.click();
const listbox = page.getByRole('listbox');
await expect(listbox).toBeVisible();
await page.getByRole('option', { name: 'Spain' }).click();
Avoid waitForTimeout unless you are diagnosing a timing bug. Time-based waiting is a common source of flaky frontend testing because it guesses when the UI is ready instead of observing it.
Test searchable selects differently from static selects
Searchable selects are often implemented as comboboxes with async filtering. They have a few extra failure modes:
- Input value and selected value can diverge
- Typing may debounce network requests
- Options may update while the user is navigating
- “No results” state may replace the list during interaction
In this case, you should test both the search flow and the final selection flow.
typescript
await page.getByRole('combobox', { name: /project/i }).fill('pay');
await expect(page.getByRole('option', { name: 'Payments Platform' })).toBeVisible();
await page.getByRole('option', { name: 'Payments Platform' }).click();
await expect(page.getByRole('combobox', { name: /project/i })).toHaveValue('Payments Platform');
If the filter is remote, you may need to intercept the network request and make the test deterministic. In Playwright, that can be done with route mocking or a fixture server. Determinism matters more than realism in a unit-like component test, while a separate integration test can cover the real API path.
Form submission tests should verify the real payload
If a custom select is part of a form, do not stop at the UI label. Check the actual submitted value. This matters when the visible text is “United States” but the stored value is us, or when a multi-select emits repeated fields.
A simple example:
typescript
await page.getByRole('button', { name: /language/i }).click();
await page.getByRole('option', { name: 'TypeScript' }).click();
await page.getByRole('button', { name: /save/i }).click();
await expect(page.getByText(‘Saved’)).toBeVisible();
For stronger assurance, assert the network request or form data in the browser context. That catches bugs where the UI looks right but the app stores the wrong value.
Multi-select needs extra coverage
Multi-select dropdowns are a different class of problem. Once users can select more than one option, you need to verify accumulation, removal, chip rendering, and keyboard navigation across already-selected items.
Test cases to include:
- Selecting multiple items updates the UI without replacing the prior choice
- Removing an item with the keyboard works
- Disabled selected values stay locked, if that is intended
- Search and selection do not reset unexpectedly after each choice
- The order of selections matches product requirements
For example, if chips are used to show selected values, assert that the chips correspond to the selected set and not just the latest item.
typescript
await page.getByRole('button', { name: /skills/i }).click();
await page.getByRole('option', { name: 'React' }).click();
await page.getByRole('option', { name: 'Playwright' }).click();
await expect(page.getByText(‘React’)).toBeVisible();
await expect(page.getByText('Playwright')).toBeVisible();
If order matters, assert the order explicitly. If order does not matter, compare normalized values rather than relying on DOM sequence.
Avoid brittle text-only assertions
Text assertions are useful, but not enough on their own. A test that checks only toHaveText('Canada') may still miss:
- Wrong option selected internally
- Missing ARIA updates
- Incorrect form payload
- Visual label changing before the state commits
Better tests mix visible state, accessible state, and behavioral state.
A practical pattern is:
- Open the dropdown
- Select an option
- Assert trigger text or input value
- Assert popup closes or remains open as expected
- Assert ARIA state updates
- Assert form or network payload matches
This sequence makes regressions easier to diagnose because each assertion tells you a different layer of the component behavior.
What flakiness looks like, and how to reduce it
Custom selects often produce flaky tests because they rely on transitions, delayed rendering, or event sequencing. Common causes include:
- Selecting by unstable CSS classes
- Clicking before the listbox is visible
- Expecting animation completion without waiting for state
- Racing against debounce timers or async fetches
- Relying on index-based option selection when labels are duplicate
Practical fixes:
- Use role-based locators
- Wait for visible state before interacting
- Mock network responses for filtered options
- Keep assertions close to the action
- Avoid arbitrary sleep calls
- Use stable test data with known labels and values
In test automation terms, this is the same principle as the broader test automation discipline: reduce non-determinism so failures reflect product regressions instead of timing noise.
A regression-safe test matrix
If you maintain a design system or reuse a select component across product areas, a small matrix is worth the effort. You do not need dozens of tests per variant, but you should cover the combinations that matter.
Recommended coverage
- Single-select, closed on selection
- Single-select, stays open after selection, if supported
- Keyboard-only selection
- Mouse selection
- Searchable select with remote data
- Disabled select
- No-results state
- Multi-select
- Portal-based rendering
- Form integration
A useful approach is to pair one broad end-to-end test with several tighter component or integration tests. The broad test proves the full user journey. The smaller tests isolate edge cases and are easier to debug.
Example: a robust Playwright test for a custom dropdown
This example combines the ideas above into a realistic flow.
import { test, expect } from '@playwright/test';
test('changes the team select and submits the form', async ({ page }) => {
await page.goto('/account/settings');
const team = page.getByRole(‘combobox’, { name: /team/i }); await team.click(); await expect(page.getByRole(‘listbox’)).toBeVisible();
await page.getByRole(‘option’, { name: ‘Platform’ }).click(); await expect(team).toHaveValue(‘Platform’); await expect(page.getByRole(‘listbox’)).toBeHidden();
await page.getByRole(‘button’, { name: /save/i }).click(); await expect(page.getByText(/saved/i)).toBeVisible(); });
This test works well because it checks the actual user-facing behavior, not internal state. If the component changes implementation from a button list to a combobox, the test may still pass as long as the accessible contract remains stable.
When to test at the component level vs the app level
For frontend testing, the right level matters.
Component-level tests
Use these when you want fast feedback on the select itself:
- Keyboard interactions
- ARIA state changes
- Controlled/uncontrolled state behavior
- Value rendering
- Disabled and error states
App-level tests
Use these when you want to verify integration:
- Form submission
- Search API wiring
- Routing after save
- Persistence to backend or local storage
- Interaction with other fields on the page
If you only test at the app level, debugging gets harder and feedback slows down. If you only test at the component level, you may miss integration issues. A healthy test strategy usually includes both.
This fits the broader definition of software testing, where different test layers answer different questions.
How CI changes the game
Custom select tests that are slightly flaky locally often become more flaky in CI because CI magnifies timing and environment differences. Headless browsers, slower machines, and parallel workers can expose race conditions.
A few CI-friendly practices help:
- Keep network mocked where possible for component behavior
- Set explicit viewport sizes if the layout changes responsively
- Run browser tests in a stable container or known environment
- Retry only after you have reduced root causes, not as a first fix
- Capture traces, screenshots, and videos for failures
If your test suite runs in a continuous pipeline, tie the dropdown tests into your broader continuous integration checks so regressions are caught before merge, not after release.
Practical checklist
If you need a fast sanity list for a new custom select component, use this:
- Does it expose the right role and accessible name?
- Can it be opened and closed with mouse and keyboard?
- Can users select an option with Enter or click?
- Does
aria-expandedreflect the popup state? - Does the active or selected option expose the right ARIA state?
- Does the selected value survive re-renders?
- Does it work inside a form submission flow?
- Does it behave correctly when rendered in a portal?
- Does search or filtering remain deterministic in tests?
- Are edge cases, like disabled or duplicate options, covered?
Final thoughts
To test custom select dropdowns well, treat them as interactive accessibility components, not decorative menus. The best tests focus on user behavior, keyboard support, and ARIA semantics, then add just enough integration coverage to prove the value flows through the app correctly.
If your test suite can answer these questions confidently, you will catch most dropdown regressions before they reach production:
- Can users operate the control without a mouse?
- Does the component expose the right semantics?
- Is the chosen value stable across renders and submissions?
- Do portal rendering, async options, and animations behave predictably under automation?
That is the difference between a test that merely clicks around and a test that protects the component through real refactors.