Resource library

QA How-To

How to Test a dropdown in Cypress (2026)

Learn cypress how to test a dropdown for native select, multi-select, custom comboboxes, async options, keyboard access, and durable value assertions.

22 min read | 2,499 words

TL;DR

For native selects call cy.get('select').select(value|label|index) and assert have.value. For custom dropdowns open the trigger, choose a role=option or data-value node, and assert the visible label plus form or API state. Stub remote options for determinism.

Key Takeaways

  • Use .select() only on native HTML select elements; custom listboxes need click, type, or keyboard flows.
  • Prefer option values over indexes so copy and ordering changes do not break tests.
  • Async option lists should be stabilized with cy.intercept and cy.wait aliases.
  • Assert form values and request bodies, not only that the menu opened.
  • Portaled menus render under body; do not scope option queries inside the trigger alone.
  • Include at least one keyboard path for critical comboboxes.
  • Dependent dropdowns must assert child reset when the parent value changes.

The reliable answer to cypress how to test a dropdown depends on the control type. Native HTML <select> elements use cy.get('select').select(...). Custom comboboxes and listboxes built with divs, lists, and ARIA roles need click, type, and keyboard flows that match real users. Mixing those strategies is the most common source of flaky dropdown tests.

This guide covers native selects (by value, text, and index), multi-select, disabled options, custom React and design-system dropdowns, keyboard accessibility checks, network-backed option loading, and assertion patterns that prove the application state changed, not only that a menu opened.

TL;DR

Control type Primary Cypress approach Assert
Native <select> .select(value | label | index) .should('have.value', ...)
Multi native select .select(['a', 'b']) selected options set
Custom menu button click trigger, click option trigger text / hidden input / API
Editable combobox type query, choose option selected chip or value
Disabled option attempt select / assert disabled remains unselected

Always identify whether you are automating a real <select> or a composite widget before writing the first command.

1. Cypress How to Test a Dropdown: Classify the Control First

Open the DOM. If you see a real <select> and <option> nodes, use Cypress select commands. If you see a button that expands a listbox of div or li nodes, you are testing a custom component.

<!-- native -->
<select data-cy="country" name="country">
  <option value="">Select country</option>
  <option value="us">United States</option>
  <option value="in">India</option>
</select>

<!-- custom (simplified) -->
<button data-cy="country-trigger" aria-haspopup="listbox">Country</button>
<ul role="listbox">
  <li role="option" data-value="us">United States</li>
</ul>

Classification mistakes look like this:

// fails or no-ops on a custom widget
cy.get('[data-cy=country-trigger]').select('India')

.select() only works on <select> elements (or on elements Cypress can treat as such). For custom widgets, interact with the trigger and options as the user would.

Stable selectors matter. Prefer data-cy on the select or trigger, and stable values on options. Visible labels can change with copy experiments. See Cypress data-cy selectors.

2. Select by Value, Display Text, and Index on Native Selects

Cypress select accepts a value, the option text content, or an index.

it('selects a country by value', () => {
  cy.visit('/profile')
  cy.get('[data-cy=country]').select('in')
  cy.get('[data-cy=country]').should('have.value', 'in')
})

By visible text:

cy.get('[data-cy=country]').select('India')
cy.get('[data-cy=country]').should('have.value', 'in')

By index (zero-based among options):

cy.get('[data-cy=country]').select(2)

Prefer value or a stable label over index. Index breaks when marketing inserts a new option at the top. Value is usually the application contract sent to the API.

When options are duplicated by text, selecting by text can be ambiguous. Prefer unique values and assert the value after selection.

cy.get('[data-cy=timezone]').select('America/New_York')
cy.get('[data-cy=timezone]').find('option:selected').should(
  'have.text',
  'Eastern Time (US & Canada)',
)

3. Multi-Select Native Dropdowns

For <select multiple>, pass an array.

it('assigns multiple roles', () => {
  cy.visit('/admin/users/u_42')
  cy.get('[data-cy=roles]').select(['admin', 'billing'])
  cy.get('[data-cy=roles]').invoke('val').should('deep.equal', [
    'admin',
    'billing',
  ])
})

Clearing multi-select selections depends on product UX. Some UIs require selecting an empty set; others use a separate Clear control. Drive the real control:

cy.get('[data-cy=roles]').select([]) // when empty selection is valid
// or
cy.get('[data-cy=clear-roles]').click()
cy.get('[data-cy=roles]').invoke('val').should('deep.equal', [])

Assert both the DOM selected state and the payload on save:

cy.intercept('PUT', '/api/users/u_42').as('saveUser')
cy.get('[data-cy=save-user]').click()
cy.wait('@saveUser').its('request.body.roles').should('deep.equal', [
  'admin',
  'billing',
])

Network assertions keep dropdown tests honest when the UI looks selected but the form state never updated. Pair with Cypress cy.intercept examples.

4. Disabled Options, Placeholder Options, and Force

Native options can be disabled. Cypress will not select a disabled option under normal conditions.

<option value="enterprise" disabled>Enterprise (contact sales)</option>
cy.get('[data-cy=plan]').select('enterprise') // fails: disabled

Test the business rule instead:

it('keeps enterprise disabled for self-serve signup', () => {
  cy.visit('/signup')
  cy.get('[data-cy=plan] option[value=enterprise]').should('be.disabled')
  cy.get('[data-cy=plan]').select('pro')
  cy.get('[data-cy=plan]').should('have.value', 'pro')
})

Placeholder options often use value="". Assert the initial empty state before selection:

cy.get('[data-cy=country]').should('have.value', '')
cy.get('[data-cy=country]').select('us')

{ force: true } can bypass some actionability checks. Use it carefully. Forcing a select on a covered element may hide a real UX bug where users cannot open the control. Prefer fixing overflow or sticky headers over force.

// last resort when a known overlay is out of scope for this test
cy.get('[data-cy=country]').select('us', { force: true })

5. Custom Dropdowns: Open, Choose, Assert Closed

Custom dropdowns usually follow open -> choose -> collapse.

it('picks a project from a custom listbox', () => {
  cy.visit('/reports')
  cy.get('[data-cy=project-trigger]').click()
  cy.get('[role=listbox]').should('be.visible')
  cy.get('[role=option]').contains('Apollo').click()
  cy.get('[data-cy=project-trigger]').should('contain', 'Apollo')
  cy.get('[role=listbox]').should('not.exist')
})

If options are virtualized, the target may not be in the DOM until you scroll the list or type a filter. Scroll within the menu container:

cy.get('[data-cy=project-menu]').scrollTo('bottom')
cy.get('[role=option]').contains('Zephyr').click()

For searchable comboboxes:

cy.get('[data-cy=assignee-input]').click().type('ana')
cy.get('[role=option]').contains('Ana Diaz').click()
cy.get('[data-cy=assignee-input]').should('have.value', 'Ana Diaz')

Avoid brittle XPaths tied to Emotion class hashes. Role plus accessible name, or data-cy on options, ages better.

6. Cypress How to Test a Dropdown Driven by Async Options

Many dropdowns fetch options after open or after typing. Stub the network so tests are deterministic.

it('loads remote customers into the combobox', () => {
  cy.intercept('GET', '/api/customers*q=acme*', {
    statusCode: 200,
    body: {
      data: [
        { id: 'c_1', name: 'Acme West' },
        { id: 'c_2', name: 'Acme East' },
      ],
    },
  }).as('searchCustomers')

  cy.visit('/invoices/new')
  cy.get('[data-cy=customer-combobox]').type('acme')
  cy.wait('@searchCustomers')
  cy.get('[role=option]').should('have.length', 2)
  cy.get('[role=option]').contains('Acme West').click()
  cy.get('[data-cy=customer-combobox]').should('contain', 'Acme West')
})

Also test empty and error states:

cy.intercept('GET', '/api/customers*', {
  statusCode: 500,
  body: { message: 'search unavailable' },
}).as('searchFail')

cy.get('[data-cy=customer-combobox]').type('acme')
cy.wait('@searchFail')
cy.get('[data-cy=customer-error]').should(
  'have.text',
  'Customer search is temporarily unavailable',
)

Without intercepts, dropdown tests flake on shared environments and slow APIs. Fixtures plus intercepts keep option sets stable. See Cypress cy.fixture when static JSON is enough.

7. Keyboard Navigation and Accessibility Checks

Dropdowns are accessibility hotspots. Even one keyboard path in E2E catches regressions that click-only tests miss.

it('moves through options with arrow keys', () => {
  cy.visit('/filters')
  cy.get('[data-cy=status-trigger]').focus().type('{enter}')
  cy.get('[role=listbox]').should('be.visible')
  cy.focused().type('{downarrow}{downarrow}{enter}')
  cy.get('[data-cy=status-trigger]').should('contain', 'In review')
})

Escape should close without changing value when that is the component contract:

cy.get('[data-cy=status-trigger]').click()
cy.get('body').type('{esc}')
cy.get('[role=listbox]').should('not.exist')

Assert ARIA state where the component sets it:

cy.get('[data-cy=status-trigger]')
  .should('have.attr', 'aria-expanded', 'false')
  .click()
  .should('have.attr', 'aria-expanded', 'true')

Deep accessibility audits belong in dedicated a11y tooling and component tests. E2E should still prove open, move, select, and close for critical comboboxes.

8. Nested Menus, Option Groups, and Dependent Dropdowns

Country then state is a classic dependent pair.

it('filters states when country changes', () => {
  cy.intercept('GET', '/api/states?country=US', {
    body: [{ code: 'CA', name: 'California' }],
  }).as('usStates')
  cy.intercept('GET', '/api/states?country=IN', {
    body: [{ code: 'KA', name: 'Karnataka' }],
  }).as('inStates')

  cy.visit('/address')
  cy.get('[data-cy=country]').select('US')
  cy.wait('@usStates')
  cy.get('[data-cy=state]').select('CA')

  cy.get('[data-cy=country]').select('IN')
  cy.wait('@inStates')
  cy.get('[data-cy=state]').should('have.value', '')
  cy.get('[data-cy=state]').select('KA')
})

Assert reset behavior when the parent changes. Many bugs leave a previous child value selected even though it is invalid for the new parent.

For native <optgroup>:

cy.get('[data-cy=timezone]').select('America/Chicago')

You still select by option value or text; the group label is organizational, not a select target by itself.

9. Assertions That Prove Selection, Not Only UI Chrome

Weak test:

cy.get('[data-cy=plan-trigger]').click()
cy.get('[role=option]').first().click()
// done? not really

Stronger test:

cy.get('[data-cy=plan-trigger]').click()
cy.get('[role=option][data-value=pro]').click()
cy.get('[data-cy=plan-trigger]').should('contain', 'Pro')
cy.get('[data-cy=plan-input-hidden]').should('have.value', 'pro')
cy.get('[data-cy=price-preview]').should('contain', '$29')

Map assertions to layers:

Layer Example assertion
Visible label trigger shows "Pro"
Form value hidden input or select value is pro
Derived UI price preview updates
Network PATCH body includes plan
Persistence reload still shows Pro

Not every test needs all layers. Critical revenue paths should cover value plus network. Pure presentation may stop at visible label and form value.

10. Flakiness Patterns Unique to Dropdown Tests

Common flake sources:

  1. Animation: menu not yet actionable. Prefer assertions on visibility and existence over fixed waits.
  2. Portal rendering: menu mounts under document.body, outside the trigger parent. Do not scope within the trigger for options.
  3. Re-render while typing: debounced search resets focus. Wait on the search intercept before choosing.
  4. Duplicate option text: first match is wrong. Use data-value.
  5. Viewport clipping: option exists but is covered. Scroll the menu container, not only the window.
  6. Strict mode / multiple elements: broad cy.get('[role=option]') matches several menus. Scope to the open listbox.
cy.get('[data-cy=assignee-menu][data-open=true]')
  .find('[role=option][data-value=u_9]')
  .click()

Retryability is your friend. Cypress retries assertions like .should('be.visible') before click when using action commands correctly. Avoid cy.wait(300) as a first fix. For deeper flake strategy, read Cypress handling flaky tests.

11. Page Objects and Custom Commands for Dropdowns

When many specs select the same design-system Select, wrap the interaction once.

// cypress/support/commands.ts
Cypress.Commands.add(
  'selectDesignSystemOption',
  (triggerSelector: string, optionValue: string) => {
    cy.get(triggerSelector).click()
    cy.get(`[role=listbox] [data-value="${optionValue}"]`).click()
    cy.get(triggerSelector).should(
      'have.attr',
      'data-value',
      optionValue,
    )
  },
)

// usage
cy.selectDesignSystemOption('[data-cy=plan-trigger]', 'pro')

Keep commands thin. They should encode interaction mechanics, not entire business workflows. Over-large commands hide failures and make stack traces harder to read. See Cypress custom commands for boundaries.

Document whether a helper is for native select or custom listbox. A single selectOption that tries both will eventually lie.

12. Practical Checklist Before Merging a Dropdown Spec

  1. Control type identified (native vs custom).
  2. Selection uses stable value or role-based option query.
  3. Initial, selected, and invalid states covered where relevant.
  4. Async options intercepted.
  5. Dependent fields reset correctly.
  6. At least one keyboard path for critical comboboxes.
  7. Assertion includes form value or network body, not only open menu.
  8. No unnecessary { force: true }.
  9. No fixed sleeps.
  10. Runs green three times locally in headed and once headless.

This checklist turns a demo click into a regression-proof dropdown test.

13. Styling, Animations, and Design-System Select Components

Design-system dropdowns often animate height, fade opacity, or move focus in ways that interact with Cypress actionability checks. Prefer asserting the open state rather than sleeping.

cy.get('[data-cy=plan-trigger]').click()
cy.get('[data-cy=plan-menu]')
  .should('be.visible')
  .and('have.attr', 'data-state', 'open')
cy.get('[data-cy=plan-menu] [data-value=pro]').click()

If the menu uses pointer-events: none during exit animations, clicking too early fails. Assert data-state or aria-hidden transitions. When the library supports reduced motion, enable it in test builds:

cy.visit('/pricing', {
  onBeforeLoad(win) {
    win.localStorage.setItem('reduceMotion', '1')
  },
})

Some Select components render the native select as visually hidden for forms while showing a custom button. In that architecture you may either:

  1. Interact with the custom UI (user fidelity), or
  2. .select() the hidden native select when it remains in the accessibility tree and is the source of truth.

Prefer the path users take for E2E confidence. Use the native path only when the custom layer is covered thoroughly at component level and E2E only needs form wiring.

Shadow DOM and Third-Party Widgets

Third-party payment or shipping widgets may place dropdowns inside shadow DOM. Cypress can pierce shadow DOM when configured (includeShadowDom: true) or when you chain .shadow() depending on your version and setup.

// only if your Cypress config enables shadow DOM piercing
cy.get('[data-cy=shipping-widget]')
  .find('select#country')
  .select('US')

If the vendor widget is not stable enough for E2E, assert that your app passed the right props via network or a test hook, and keep deep widget coverage in vendor sandboxes. Do not spend weeks reverse-engineering a closed widget for marginal value.

14. Internationalization, Long Labels, and Overflow Menus

Dropdown tests written against English labels break when the app is localized. Prefer data-value and roles over visible strings when i18n is in scope.

// brittle under i18n
cy.get('[role=option]').contains('United States').click()

// durable
cy.get('[role=option][data-value=US]').click()

Long translated labels can wrap or push chevrons. A useful secondary assertion is that the selected trigger remains visible and not truncated into unusable UI, but avoid pixel-perfect width checks. If truncation is a known design rule, assert title tooltips or expandable text if the product provides them.

Overflow menus (three-dot actions) are dropdown cousins. Apply the same open/choose/assert pattern and ensure destructive items still confirm correctly. For native confirms after a menu action, combine with the alert handling patterns from your suite's dialog helpers.

15. Form Libraries, Controlled Values, and Validation Timing

React controlled selects may validate on blur, change, or submit. Align assertions with that timing.

it('requires a country before submit', () => {
  cy.visit('/onboarding')
  cy.get('[data-cy=continue]').click()
  cy.get('[data-cy=country-error]')
    .should('have.text', 'Country is required')
  cy.get('[data-cy=country]').select('US')
  cy.get('[data-cy=country-error]').should('not.exist')
  cy.get('[data-cy=continue]').click()
  cy.location('pathname').should('eq', '/onboarding/company')
})

When using Formik, React Hook Form, or similar, the visible select value and the form state should match after selection. If they diverge, you found a controlled component bug. Asserting only the DOM selected attribute can miss form state that never updates.

Server-side validation may reject a client-selected value (for example, a plan not available in a region). Cover at least one such path with an intercept returning 422 and assert the error surfaces next to the dropdown, not only as a generic toast.

cy.intercept('POST', '/api/onboarding', {
  statusCode: 422,
  body: { errors: { country: 'Not supported yet' } },
}).as('save')
cy.get('[data-cy=country]').select('ZZ')
cy.get('[data-cy=continue]').click()
cy.wait('@save')
cy.get('[data-cy=country-error]').should('contain', 'Not supported yet')

16. Migration Notes: Selenium-Style Select Patterns Versus Cypress

Engineers coming from Selenium often look for Select class helpers. Cypress does not use that Java API. The conceptual mapping is:

Selenium idea Cypress approach
new Select(element).selectByValue .select(value)
selectByVisibleText .select('Visible text')
selectByIndex .select(index)
custom bootstrap-select click + option click

Do not wrap Cypress in a Selenium-like facade that hides whether the control is native. Explicitness helps the next reader. When porting a suite, reclassify every dropdown; many WebDriver tests used brittle XPath that should become roles and test ids during migration.

Also note Cypress automatic retry differs from typical WebDriver explicit waits. You rarely need to wait for options to exist before .select if you first ensure the select is present and options are rendered; for async options, still wait on the network that populates them.

17. End-to-End Scenarios That Combine Dropdowns With Other Controls

Real forms rarely contain a single select. Combine dropdown coverage with dates, uploads, and toggles only when the integration risk is real, and keep the scenario short.

it('creates a ticket with priority, assignee, and due date', () => {
  cy.intercept('POST', '/api/tickets').as('createTicket')
  cy.visit('/tickets/new')

  cy.get('[data-cy=title]').type('Flaky login on Safari')
  cy.get('[data-cy=priority]').select('high')
  cy.get('[data-cy=assignee-trigger]').click()
  cy.get('[role=option][data-value=u_ana]').click()
  cy.get('[data-cy=due-date]').type('2026-08-01')
  cy.get('[data-cy=submit-ticket]').click()

  cy.wait('@createTicket').its('request.body').should('deep.include', {
    priority: 'high',
    assigneeId: 'u_ana',
  })
  cy.location('pathname').should('match', /\/tickets\//)
})

This style of test still treats the dropdown as a first-class control with stable values. It does not expand into a full journey test that hides which assertion failed. If the suite already has strong field-level tests, limit combined scenarios to one happy path per form.

Analytics and Telemetry Hooks

Product analytics sometimes fire when users open or change dropdowns. If your team asserts analytics in E2E, stub the collector and assert a single event shape rather than order-sensitive noise.

cy.intercept('POST', '/api/telemetry', (req) => {
  req.reply({ statusCode: 204 })
}).as('telemetry')

cy.get('[data-cy=plan-trigger]').click()
cy.get('[data-cy=plan-menu] [data-value=pro]').click()
cy.wait('@telemetry')

Most teams should not block releases on telemetry E2E. Mention it only when analytics correctness is a stated acceptance criterion.

18. Review Rubric for Dropdown Pull Requests

When reviewing a PR that adds or changes dropdown tests, check:

  1. Native versus custom approach is correct and documented in a one-line comment if non-obvious.
  2. Option targeting uses value or role, not fragile CSS nth-child chains.
  3. Async options are intercepted with fixtures.
  4. Assertions include value or network proof for critical paths.
  5. No new fixed waits.
  6. Keyboard coverage exists for new combobox primitives in the design system.
  7. i18n-safe selectors if the app is localized.
  8. Helper reuse for the design-system Select rather than copy-paste open/click blocks.

A PR that only opens and closes a menu without selecting is not coverage. A PR that selects by index without justification should be updated before merge. This rubric keeps cypress how to test a dropdown knowledge consistent across the team as people rotate through features.

Interview Questions and Answers

Q: How do you select an option in a native HTML select with Cypress?

I query the <select> and call .select() with the option value, visible text, or index. Then I assert .should('have.value', expected) and, for critical flows, the request payload on submit.

Q: Why does .select() fail on our React dropdown?

Because it is probably not a native <select>. Custom listboxes need click or keyboard interaction on the trigger and options. I inspect the DOM before choosing the API.

Q: How do you test a multi-select?

For native multi selects I pass an array to .select(['a', 'b']) and assert the values with invoke('val'). For custom multi-select chips I open the menu, click multiple options, and assert chips plus payload.

Q: How do you handle options loaded from an API?

I cy.intercept the search or list endpoint, open or type into the control, cy.wait the alias, then choose a known option from the fixture. I also test empty and error responses.

Q: How do you avoid flaky dropdown tests?

I scope option queries to the open listbox, wait for network aliases on async options, avoid index-based selection when possible, and assert visibility instead of adding fixed waits. I also watch for portals rendered outside the parent.

Q: When is selecting by index acceptable?

Almost never in durable suites. I may use index in a quick spike. For committed tests I prefer option values that match the application contract.

Q: How do you verify a dropdown selection persisted?

I save the form, assert the network body, reload the page or revisit the entity, and assert the control still shows the selected value.

Common Mistakes

  • Calling .select() on a custom div dropdown.
  • Selecting by index that shifts when options change.
  • Asserting only that the menu opened.
  • Using within(trigger) and missing portaled options under body.
  • Ignoring disabled option business rules.
  • Forgetting to reset dependent dropdowns in assertions.
  • Hard-waiting 2 seconds for search results instead of intercepting.
  • Force-clicking covered options and shipping an unusable control.
  • Matching options by partial text that collides across rows.
  • Duplicating 20 lines of open/type/click without a small helper on a design-system control.

Conclusion

For cypress how to test a dropdown, start by classifying native versus custom controls. Use .select() for real <select> elements and user-like click, type, and keyboard flows for composite widgets. Stabilize async options with intercepts, assert values and side effects, and treat accessibility keyboard paths as part of quality for critical filters and forms.

Next step: pick one flaky dropdown in your suite, rewrite it with a stable data-value option query plus a network assertion, and delete any fixed cy.wait around that menu.

Interview Questions and Answers

Explain how you test native versus custom dropdowns in Cypress.

Native selects use .select and have.value assertions. Custom listboxes use trigger clicks, option roles or test ids, and assertions on visible selection plus form state. I inspect the DOM before choosing an approach.

How do you make dropdown tests deterministic with remote data?

I intercept list or search endpoints with fixtures, wait on aliases after typing or opening, then select a known option. Live backend cardinality is not allowed to define assertion lengths.

What assertions prove a dropdown selection mattered?

I check the control value, derived UI such as price or label, and for critical paths the network request body. Opening the menu alone is not a complete test.

How do you test dependent country and state dropdowns?

I select the parent, wait for child options to load via intercept, select a child, then change the parent and assert the child resets before choosing a new valid child.

How do keyboard interactions fit dropdown testing?

For critical comboboxes I open via enter or click, move with arrows, select with enter, and close with escape when that is the contract. It catches accessibility regressions click-only tests miss.

Why are option indexes risky?

New options shift indexes and tests select the wrong business value while still passing. Values aligned to the API contract are stable.

How do portaled dropdown menus affect selectors?

Options may mount under document.body outside the trigger parent. Queries must target the open listbox globally or via a menu test id, not only within the trigger.

Frequently Asked Questions

How do I select a dropdown option in Cypress?

For a native select, use .select with the option value, text, or index. For custom widgets, click the trigger and then the option element. Always assert the resulting value.

Why does select() fail on my React dropdown?

The control is likely not a native select. Custom comboboxes and menus must be automated with user-like clicks, typing, and keyboard events instead of .select().

How do I select multiple options?

On a native multi select, pass an array to .select(['a','b']) and assert invoke('val'). On custom multi-selects, open the menu and select each option or chip control the product provides.

How do I test a searchable combobox with API results?

Intercept the search endpoint, type the query, wait for the alias, then click a known option from the fixture. Also cover empty and error responses.

How do I assert which option is selected?

For native selects use should('have.value'). For custom controls assert the trigger text, a hidden input, data attributes, and ideally the submit payload.

Should I use force:true when selecting?

Only as a last resort. Force can hide actionability bugs such as overlays blocking the control. Prefer making the control reachable as a user would.

How do I handle disabled options?

Assert the option is disabled and select a valid alternative. Cypress will not select a disabled native option under normal conditions, which is the correct failure when tests try.

Related Guides