Working with Select elements and Select2 widgets in Cypress

March 20, 2020

By Gleb Bahmutov

This blog post shows how to control native <select> HTML elements from Cypress tests. We will also look at how to work with a very popular wrapper library called Select2, that supplants the native <select> elements with an additional HTML markup.

Note: you can find the source code for this post in the recipe "Select widgets".

Single value select element

Let's take a page where a user selects a single state from a list of choices. Maybe it is their home state, or the state they would like to avoid where their nemesis lives.

<label for="my-state">
  Pick one state
</label>
<select id="my-state" name="my-state" style="width: 50%">
  <optgroup label="Alaskan/Hawaiian Time Zone">
    <option value="AK">Alaska</option>
    <option value="HI">Hawaii</option>
  </optgroup>
  <optgroup label="Pacific Time Zone">
    <option value="CA">California</option>
    <option value="NV">Nevada</option>
    ...
</select>

Modern browsers render the native <select> pretty well. Here is Chrome 80 showing the list of states grouped by timezone.

The select element in Chrome 80

We can set the selected state from a Cypress test using the .select command.

beforeEach(() => {
  cy.visit('index.html')
})

describe('HTML select element', () => {
  context('single value', () => {
    it('sets MA', () => {
      // https://on.cypress.io/select
      // set using value
      // <option value="MA">Massachusetts</option>
      cy.get('#my-state').select('MA')

      // confirm the selected value
      cy.get('#my-state').should('have.value', 'MA')
    })
  })
})

Note: to skip repeating the same code the following test snippets will omit beforeEach and describe blocks.

Single select Cypress test in action

Cypress command .select allows one to select an option using its value or its text content. The next test selects the option with text content "Massachusetts".

it('sets Massachusetts', () => {
  // https://on.cypress.io/select
  // set using text
  // <option value="MA">Massachusetts</option>
  cy.get('#my-state').select('Massachusetts')

  // confirm the selected value
  cy.get('#my-state').should('have.value', 'MA')
})

Selecting multiple values

HTML allows a single <select> element to have multiple options to be picked

<label for="my-states">
  Pick several states
</label>
<select id="my-states" name="my-states" multiple style="width: 50%">
  ...
</select>

The test works the same way - we just need to be careful when checking the selected value - we need to use deep equality to compare the two arrays.

it('adds several states', () => {
  // https://on.cypress.io/select
  // select multiple values by passing an array
  cy.get('#my-states').select(['MA', 'VT', 'CT'])

  // confirm the selected value - note that the values are sorted
  // and because it is an array we need to use deep equality
  // against the yielded list from ".invoke('val')"
  cy.get('#my-states').invoke('val').should('deep.equal', ['CT', 'MA', 'VT'])
})

The test works and picks 3 states

Multiple selections

Tip: note the blue outline around the <select> element after the test is finished. The element still has the browser focus - which can surprise you while interacting with the Cypress GUI. For example, when pressing "r" to re-run the tests, you will instead change the state to "Rhode Island"!

Clicking "r" after the test is finished ... selects "Rhode Island" (and nobody wants that)

Thus, this particular test is nice to end with blurring the <select> element

it('adds several states', () => {
  // https://on.cypress.io/select
  // select multiple values by passing an array
  cy.get('#my-states').select(['MA', 'VT', 'CT'])

  // confirm the selected value - note that the values are sorted
  // and because it is an array we need to use deep equality
  // against the yielded list from ".invoke('val')"
  cy.get('#my-states').invoke('val').should('deep.equal', ['CT', 'MA', 'VT'])

  // remove the focus from <select> element
  cy.get('#my-states').blur()
})
The focus is removed from the select element

Single value using Select2

The standard <select> element is ok, but the wrapper libraries like Select2 make it even better. First, you need to load the jQuery and the Select2 libraries and initialize the widget like this:

<head>
  <script src="https://code.jquery.com/jquery-3.4.1.min.js"
    integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
    crossorigin="anonymous"></script>
  <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/select2.min.css" rel="stylesheet" />
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/select2.min.js"></script>
</head>
<body>
  <label for="favorite-state">
    Pick your favorite state
  </label>
  <select class="js-example-basic-single" id="favorite-state"   style="width: 50%">
    ...
  </select>
  <script src="app.js"></script>
</body>

The "app.js" file creates the HTML widget that shows up in place of the <select> element.

// app.js
$(document).ready(function () {
  $('.js-example-basic-single').select2()
})

The Select2 widget to me looks nicer than the stock <select> element, plus it has autocomplete input that is helpful.

Select2 single value widget

If we write a Cypress test to set the single value against the <select> element wrapped using Select2 ... it will fail.

it('selects Massachusetts', () => {
  cy.log('--- Force select ---')

  // https://on.cypress.io/select
  cy.get('#favorite-state').select('MA')

  // confirm the value of the selected element
  cy.get('#favorite-state').should('have.value', 'MA')
})
Failed test because Select2 markup covers the original <select> element

When Select2 renders itself, it hides the original <select> element, leaving just a single pixel-high rectangle. Cypress checks if the element is visible before selecting a value, and rejects such barely visible elements, because the user could not select an element this way.

The HTML markup shows why Cypress refuses to act on the original <select> element

We can force Cypress to by-pass its built-in .select checks by using force: true option.

it('selects Massachusetts', () => {
  cy.log('--- Force select ---')

  // https://on.cypress.io/select
  cy.get('#favorite-state').select('MA', { force: true })

  // confirm the value of the selected element
  cy.get('#favorite-state').should('have.value', 'MA')
})
The test forces the element to select a value

We have the <select> element with the right value - but does the Select2 HTML wrapper show the right state's name? To see how the widget renders the selection, inspect its markup using the DevTools Element panel after the test has finished.

Find the element using the DevTools Elements panel

You can find the same selector even faster by using the Cypress Selector Playground which tries to pick a good selector for a visible element.

Picking the element using the Cypress Selector Playground tool

After you click the "Copy" button, you have the entire cy.get('#select2-favorite-state-container') command in your clipboard - paste it in your test and go!

it('selects Massachusetts', () => {
  cy.log('--- Force select ---')

  // https://on.cypress.io/select
  // avoid error message that select element is covered
  // by the rendered Select2 widget by using "force: true" option
  cy.get('#favorite-state').select('MA', { force: true })

  // confirm the value of the selected element
  cy.get('#favorite-state').should('have.value', 'MA')

  // confirm Select2 widget renders the state name
  cy.get('#select2-favorite-state-container').should('have.text', 'Massachusetts')
})
Confirm the text shown by the Select2 widget

Selecting single value by typing

Let's select a state by typing part of its name. Here is where knowing the HTML markup the Select2 creates is crucial in order to send the right commands from the test. Luckily, we can always inspect the HTML structure using the DevTools.

First, notice that the user has to click the widget before the the input element appears.

User first clicks the Select2 widget

Let's click from our test too, but we can't click on the original <select> element - instead let's click on the HTML element Select2 adds to the page. We can use the Cypress Selector Playground tool just like before to pick the selector and the command and copy paste them into the test

it('selects Massachusetts by typing', () => {
  cy.log('--- Pick state by typing ---')
  cy.get('#select2-favorite-state-container').click()
})

The test opens the input widget, and we can check the selector for the input element to type into.

Selector for the input element created by Select2 widget

We could copy the selector cy.get('.select2-search') suggested by the tool BUT this is very fragile. Notice in this case the selector does not have anything connecting it to the original <select class="js-example-basic-single" id="favorite-state"> element. If our page has several Select2 widgets, the cy.get('.select2-search') command would return all of them breaking the test, because Cypress refuses to type into multiple elements at once.

In situations likes this, you need to look closely at the HTML markup to see if there is anything specific that ties the widget back to its original element.

Inspecting the Select2 HTML markup

Notice the Select2 widget has an attribute that has a part of the original <select> name - the aria-control=select2-favorite-state-results. We can use this attribute as a selector!

it('selects Massachusetts by typing', () => {
  cy.log('--- Pick state by typing ---')
  cy.get('#select2-favorite-state-container').click()

  cy.get('input[aria-controls="select2-favorite-state-results"]').type('Mass{enter}')
})
Typing in the selected element

We can extend the test to confirm the selected value and the shown text are correct

it('selects Massachusetts by typing', () => {
  cy.log('--- Pick state by typing ---')
  // first, open the widget
  cy.get('#select2-favorite-state-container').click()
  // then type into the widget's input element
  // use custom selector to handle cases when there are multiple widgets
  // on the page
  cy.get('input[aria-controls="select2-favorite-state-results"]').type('Mass{enter}')

  // confirm the value of the selected element
  cy.get('#favorite-state').should('have.value', 'MA')

  // confirm Select2 widget renders the state name
  cy.get('#select2-favorite-state-container').should('have.text', 'Massachusetts')
})

Just to show the widget in action, you can delay typing each character using .type command's options:

cy.get('input[aria-controls="select2-favorite-state-results"]')
  .type('Mass{enter}', {
     delay: 500,
  })
cy.type with delay after each character

Multiple values using Select2

Let's move to the next situation: when Select2 widget wraps a <select> element that allows picking multiple values. The <select> HTML markup is:

<label for="states">
  Pick several states
</label>
<select class="js-example-basic-multiple" id="states"
  name="states" multiple="multiple" style="width: 90%">
  ...
</select>

We can set the values directly on the select#states element from the test:

it('adds several states', () => {
  // https://on.cypress.io/select
  // select multiple values by passing an array
  // again, have to use "force": "true" because the actual
  // select is covered by Select2 widget
  cy.get('#states').select(['MA', 'VT', 'CT'], { force: true })

  // confirm the selected value - note that the values are sorted
  // and because it is an array we need to use deep equality
  // against the yielded list from ".invoke('val')"
  cy.get('#states').invoke('val').should('deep.equal', ['CT', 'MA', 'VT'])
})
The test selects multiple values

How do we confirm the "pills" displayed by the Select2 widget - the x Connecticut, x Massachusetts, x Vermont we see there? Unfortunately, Cypress Selector Playground is really geared towards selecting individual elements, as the next screenshot shows

Selector Playground suggest the selector for each element

Let's open DevTools console and look at the markup using the Elements panel.

Markup for <select> element is followed by Select2 widget

Our original element select#states is immediately followed by .select2 element that contains the list items with every selected state. The combined selector for this would be #states + .select2 .select2-selection__choice - first it finds an element with id states, then the next element (CSS + operator) with class select2, then inside an element with class select2-selection__choice. Let's write our test:

it('adds several states', () => {
  // https://on.cypress.io/select
  // select multiple values by passing an array
  // again, have to use "force": "true" because the actual
  // select is covered by Select2 widget
  cy.get('#states').select(['MA', 'VT', 'CT'], { force: true })

  // confirm the selected value - note that the values are sorted
  // and because it is an array we need to use deep equality
  // against the yielded list from ".invoke('val')"
  cy.get('#states').invoke('val').should('deep.equal', ['CT', 'MA', 'VT'])

  // confirm Select2 widget renders the 3 state names
  cy.get('#states + .select2 .select2-selection__choice')
  // use ".should(cb)" to confirm the displayed values
  // https://on.cypress.io/should#Function
  .should((list) => {
    expect(list[0].title).to.equal('Connecticut')
    expect(list[1].title).to.equal('Massachusetts')
    expect(list[2].title).to.equal('Vermont')
  })
})

The test confirms that these New England states are indeed what we wanted to select.

Three selected states

What if we want to find states by typing, not by setting them by value? Let's type using the markup shown by the Elements panel. The test delays each character by 500ms for clarity

it('adds several states by typing', () => {
  // Select2 HTML widget is inserted after the corresponding <select> element
  // thus we can find it using " + " CSS selector
  cy.get('#states + .select2').click()
  // after we click on the Select2 widget, the search drop down and input appear
  .find('.select2-search')
  // type parts of the states' names, just like a real user would
  .type('Verm{enter}Mass{enter}Conn{enter}', { delay: 500 })

  // confirm the selected value - note that the values are sorted
  // and because it is an array we need to use deep equality
  // against the yielded list from ".invoke('val')"
  cy.get('#states').invoke('val').should('deep.equal', ['CT', 'MA', 'VT'])
})
Typing multiple states and asserting the selection

Removing selected item

What if the user selects a few items, then removes one of them? Can we test that? Again, let's look at the markup after selecting a few items.

Remove an item markup

Each Select2 item has a span with class select2-selection__choice__remove. It is a mouthful, but we can use it to remove an item from the test.

it('removes state', () => {
  cy.get('#states + .select2').click()
    .find('.select2-search')
    .type('Verm{enter}Mass{enter}Conn{enter}')

  // confirm 3 states are selected
  cy.get('#states').invoke('val')
    .should('deep.equal', ['CT', 'MA', 'VT'])

  // remove Connecticut, they don't have a major league team anyway
  cy.get('#states + .select2')
    .contains('.select2-selection__choice', 'Connecticut')
    .find('.select2-selection__choice__remove').click()

  // when removing an item, Select2 automatically expands choices
  // we can close them by pressing "Enter"
  cy.get('#states + .select2 .select2-search')
    .type('{enter}')

  // confirm the remaining selections
  cy.get('#states').invoke('val').should('deep.equal', ['MA', 'VT'])
})

The test is fast - blink, and you will miss it - but you can still see a state being added and deleted correctly.

Removing one of the selections by clicking test

Programmatic control

Select2 allow the application code to retrieve selected items programmatically. For example, you can get currently selected items by calling method select2 with argument data.

$('#mySelect2').select2('data');

Cypress tests can directly invoke this method from the test - because the test runs inside the browser.

it('returns selected items', () => {
  cy.get('#states').select(['MA', 'VT', 'CT'], { force: true })
  // https://select2.org/programmatic-control/retrieving-selections
  cy.get('#states').invoke('select2', 'data')
})

Once the test finishes, click on the INVOKE command to see the yielded value - it is a list of selected states (with extra properties added by Select2 code)

The selected items returned by the select2('data') call

From each object, let's pick the text fields and assert the expected states are selected. Cypress bundles Lodash that allows us to map each object to its property elegantly.

it('returns selected items', () => {
  cy.get('#states').select(['MA', 'VT', 'CT'], { force: true })
  // https://select2.org/programmatic-control/retrieving-selections
  cy.get('#states').invoke('select2', 'data')
    .should((list) => {
      const names = Cypress._.map(list, 'text')
      expect(names).to.deep.equal([
        'Connecticut', 
        'Massachusetts', 
        'Vermont'
      ])
    })
})
Asking Select2 for selected items and confirming the list of state names

Fetched Data

Select2 allows to load choices from a remote server using an Ajax call. Here is the initialization code fetching a list of users:

$('.js-example-remote-data').select2({
  placeholder: 'Pick a user',
  ajax: {
    url: 'https://jsonplaceholder.cypress.io/users',
    dataType: 'json',
    delay: 250,
    processResults (data) {
      return {
        results: $.map(data, function (item) {
          return {
            text: item.name,
            id: item.id,
          }
        }),
      }
    },
  },
})

Our test should be careful not limit the element search to the initial empty list of choices. For example, this test WILL NOT work

it('selects a value - WILL NOT WORK, JUST A DEMO', () => {
  // clicking on the container starts Ajax call
  cy.get('#select2-user-container').click()

  // we know the choice elements have this class
  cy.get('.select2-results__option')
    // so let's try finding the element with the text
    // WILL NOT WORK
    .contains('Leanne Graham').click()

  // confirm the right user is found
  cy.get('#user').should('have.value', '1')
  cy.get('#select2-user-container').should('have.text', 'Leanne Graham')
})

The above test fails - even though we can see the user with the name we are looking for.

Test fails despite showing the right user name in the list

The reason for the failure is described in our Retry-ability Guide. By splitting the command cy.get(...).contains(...) the test "falls" into a trap. It finds the loading placeholder element, and then only searches that part of the DOM, while the true list is created in a different DOM element. You can see the problem by hovering over the "get .select2-results__option" command.

.get command shows the element we are searching

There are several solutions to this problem, all shown in cypress-example-recipes repository. Perhaps the simplest one is to use a single command to query the element by text.

it('selects a value by retrying', () => {
  // clicking on the container starts Ajax call
  cy.get('#select2-user-container').click()

  // we need to retry getting the option until it is found
  // then we can click it. To retry finding the element with text
  // we should use https://on.cypress.io/contains
  cy.contains('.select2-results__option', 'Leanne Graham').click()

  // confirm the right user is found
  cy.get('#user').should('have.value', '1')
  cy.get('#select2-user-container').should('have.text', 'Leanne Graham')
})

The cy.contains command retries until Select2 fetches and displays the list of options and an option with text "Leanne Graham" is present.

Conclusions

If you are working with a library that wraps a native HTML element with a widget, like Select2 does, it is important to know the additional markup. The test has to interact with the new markup to control the widget and assert it is wired correctly. In this post, we have looked at the native <select> element and its wrapper Select2 and how Cypress end-to-end tests can work in both situations.