Skip to main content
Skip table of contents

Writing keyboard-only end-to-end tests

Why do we need keyboard-only tests?

We already have Cypress end-to-end (e2e) tests in place with accessibility (axe core) checks, but that only covers part of the accessibility checks that need to get done. Adding keyboard-only e2e tests ensure that all elements can be accessed via tabbing, buttons and links are tabbable and focusable and that it is possible to navigate the app completely using only a keyboard. For more information, read w3.org understanding success criterion 2.1.1 for keyboard.

Why now?

At the end of April 2022, Cypress keyboard commands were incorporated into the platform testing code. And the platform team did not have any support regarding keyboard-only tests. It is now available, and engineers are encouraged to start adding these tests to their application.

Are there any limitations?

Automated accessibility checks done using axe-core are able to detect focus outline and order issues, but doesn’t test for keyboard traps (criterion 2.1.2) or ensure that all content is operable from the keyboard (AAA criterion 2.1.3). We want to strive for accessibility beyond compliance, and this is one way that we plan on doing it.

That said, there are limitations on keyboard-only testing that may not have a solution. The library used to emulate “real” keypresses does not currently support:

Cypress keyboard-specific commands

Platform developer docs has a list of keyboard testing helper functions which are used for keyboard-specific testing. But as of this writing, the list isn’t complete. The following is an abbreviated list of commands from the platform docs with links to get more details. Commands without links have been recently added, but are not yet documented:

Useful commands

  • Real keypress (cy.realPress(String|String[])) executes a single keypress, or multiple simultaneous keypresses. Pass in a key or array of keys, e.g. ['Shift', 'Tab'] to tab up.

  • Execute a sequence of keypresses (cy.keys(String[]))

  • Repeat a keypress (cy.repeatKey(String|String[], Number))

  • Tab forward or backward (boolean direction) to a specified element using a CSS selector (cy.tabToElement(String, direction))

  • On a focused radio input, choose a radio option (cy.chooseRadio(String))

  • On a focused input, type in a value (cy.typeInFocused(String))

  • On a focused select, choose an option by typing (cy.chooseSelectOptionByTyping(String))

    This command was added because using arrow keys inside of an option select popup doesn’t work on Macs (see the previous limitations section for details).

  • On a focused select, choose an option by its value (cy.chooseSelectOptionUsingValue(String))

    Using the chooseSelectOptionByTyping command doesn't work as expected for options where the value and text don't match. This command will find the String option value, then use the chooseSelectOptionByTyping command to select the option by the text of the option (since using arrow keys don't work on a Mac).

  • Tab to and activate a form start button, or action link, on the form Introduction  page (cy.tabToStartForm())

    This command was added as a shortcut, but if your page has multiple action links with a different destination, you'll need to target them using a custom selector instead of this command. 

  • Tab to and activate continue to next form page button (cy.tabToContinueForm())

    This command was added as a shortcut for tabbing to a submit-type button and "real" pressing the spacebar.

  • Tab to and activate submit button, for forms on review & submit page (cy.tabToSubmitForm())This command was added as a shortcut for tabbing to the submit form button (not a submit type button) on the review and submit page, and submitting the form.

  • Tab to input with label text (cy.tabToInputWithLabel(String))In some situations it is easier to tab to an input based on the label text. Partial text from the String parameter is matched, so no need to include all text.

  • Tab to an element and type in it if the data is truthy (cy.typeInIfDataExists(String, String))This is useful for blocks of inputs that may not all be filled in, e.g. address line 3, which is only filled in when data is available. The first String selector targets the input, and the second String is the text to enter

  • Tab to a checkbox and set a state, if the current state doesn’t match the set state (cy.setCheckboxFromData(String, Boolean))

    This uses a String selector to target the checkbox and set it based on the Boolean value, which defaults to false (unchecked)

  • Tab to named full name group of elements and fill it in (cy.typeInFullName(String, Object)

    The String is the field name of the group added as part of each element ID, e.g. veteranName will search for an ID of veteranNamefirst (for the first name). This command will also select the prefix, fill in the first, middle and last name as well as the suffix select. The Object parameter includes values for prefix, first, middle, last and suffix string values.

  • Tab to date group and fill in the date (cy.typeInDate(String, String))

    The first String is the field name of the date group added as part of each element ID or as part of the name for the year input. The second String contains the date in "YYYY-MM-DD" format.

Evaluative commands

Check expected internal tabbable count (cy.hasTabbableCount(selector, count))

How to use these commands

Important note: Always make sure to target the elements in tab order. Or, make sure to use Shift + Tab to tab backwards when searching for an element. If an element isn't found, the tabToElement command will continue into the page footer before showing an error.

Pages with no form elements

Pages with no form elements, e.g. a Veteran information page, can be skipped by using the tab to continue command. These pages usually don’t need an accessibility check since they are already checked by the non-keyboard-only tests

CODE
// Veteran info page
cy.tabToContinueForm();

Introduction page

If the application includes a wizard, find and follow one of the examples in the sections that follow. It’ll most likely include a grouped radio button. If the introduction page doesn’t include a wizard, the start button may be a green action link

CODE
// Introduction page green action link
cy.tabToElement('.vads-c-action-link--green');
cy.realPress('Enter');

Or, a green button if the page isn’t up-to-date.

CODE
// Introduction page green start button
cy.tabToElement('button[id$="continueButton"].usa-button-primary');
cy.realPress('Enter');

Both types have been included in this Cypress shortcut command

CODE
// Introduction page green button or link
cy.tabToStartForm();

If your introduction page includes multiple links or buttons that have different destinations, don’t use the cy.tabToStartForm command. Target the start button or link individually.

If you encounter a case where the application in progress (blue) box appears, it may mean that your mock user has the in_progress_forms value set improperly. You can either update the mock user, or log in as follows:

CODE
cy.login({
  data: {
    attributes: {
      ...mockUser.data.attributes,
      // eslint-disable-next-line camelcase
      in_progress_forms: [], // clear out in-progress state
    },
  },
});

Grouped radio buttons

Grouped radio buttons can show up as either a yes or no combination or a larger group of choices. Either way, they can be set using the chooseRadio command:

CODE
// Y/N radios. Target the name attribute
cy.tabToElement('[name="root_foo"]');
cy.chooseRadio(data.foo);

Checkboxes and grouped checkboxes

A group of checkboxes it’s easier to target the label using tabToInputWithLabel command. The label text is case-sensitive:

CODE
// Target the checkbox label
cy.tabToInputWithLabel('Foo');
cy.realPress('Space');

If you target more than one checkbox, make sure to do it in order. Internally this command uses tabToElement, but only in the forward direction. The Notice of Disagreement and Higher-Level Review forms have a case where a list of cards is shown, but they are sorted by most recent date. Arbitrarily setting multiple checkboxes by label may cause the tabToElement command to tab into the page footer and not find the label.

Select option methods

To select an option, the Cypress command has to use typing of the option text in order to select it. This is because the up and down arrows don’t work as expected on a Mac computer, see the section about keyboard limitations for more details.

When an option has a value and text that matches, use the findSelectOptionByTyping command

CODE
// focus and select the option by its label text
// e.g. <option value="Bar">Bar</option>
cy.tabToElement('select[name="foo"]');
cy.chooseSelectOptionByTyping(data.foo || "Bar" );

Or, if the option value doesn’t match, you’ll need to use chooseSelectUsingValue command

CODE
// Choose option by value, e.g. <option value="1">One</option>
cy.tabToElement('select[name="foo"]');
cy.chooseSelectOptionUsingValue(data.foo || "1");

Inputs

For pages with a lot of form elements, you can use one of these methods:

Target the element by CSS selector and type in the text

CODE
cy.tabToElement('#root_foo');
cy.typeInFocused('Foo is the word');

Or, only target the input by CSS selector, and if the data value exists, then type in the value. This method is useful for data that is missing, e.g. a street 3 address doesn’t always have a value, so no reason to tab to it and attempt to type in an undefined or null value.

CODE
cy.typeInIfDataExists('#root_foo', data.foo);

Or, lastly only target the input by label and type in the text:

CODE
cy.tabToInputWithLabel('Foo or Bar?');
cy.typeInFocused('Foo is a word');

Grouped elements

There are some shortcut commands for entering a full name (prefix, first, middle, last and suffix), which may or may not include all entries in a form. The prefix and suffix are select elements, if they are included, and the remaining entries are inputs.

CODE
// Mock “fullName” data
const data = {
  fullName: {
    refix: null,
    first: 'Mike',
    middle: undefined,
    last: 'Wazowski',
    suffix: 'Esq',
  },
};

// Fill in “fullName” field
cy.typeInFullName('fullName', data);

There is also a typeInDate shortcut command for YYYY-MM-DD format. At the time of this writing, this command only supports the React Date component (not the va-date web component)

CODE
// Fill in “startDate” field
cy.typeInDate('startDate', '2021-12-31');

Review and submit page

The review and submit page usually has a privacy checkbox that needs to be checked before submitting the form. The accordions on this page could be tested, but that would require tabbing through all of the edit buttons to get to the next accordion. This would lengthen the time required to test the form, but you can make that determination.

CODE
// *** Review & Submit ***
cy.setCheckboxFromData('[name="privacyAgreementAccepted"]', true);
cy.tabToSubmitForm();


JavaScript errors detected

Please note, these errors can depend on your browser setup.

If this problem persists, please contact our support.