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:
The current keyboard commands are not yet set up to work with web components, e.g.
va-text-input
,va-select
,va-date
, etcKeyboard-only tests are a lot slower than the other Cypress tests. So it might be best to have them run a short “happy-path” through the form
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 thechooseSelectOptionByTyping
command doesn't work as expected for options where the value and text don't match. This command will find theString
option value, then use thechooseSelectOptionByTyping
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 enterTab to a checkbox and set a state, if the current state doesn’t match the set state (
cy.setCheckboxFromData(String, Boolean)
)
This uses aString
selector to target the checkbox and set it based on theBoolean
value, which defaults to false (unchecked)Tab to named full name group of elements and fill it in (
cy.typeInFullName(String, Object)
TheString
is the field name of the group added as part of each element ID, e.g.veteranName
will search for an ID ofveteranNamefirst
(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. TheObject
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 firstString
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 secondString
contains the date in "YYYY-MM-DD" format.
Evaluative commands
Evaluate a select menu (cy.allyEvaluateSelectMenu(selectMenu, optionText, selectedOption))
Evaluate a group of radio buttons (cy.allyEvaluateRadioButtons(selectorArray, arrowPressed, reversed = false))
Evaluate an input (cy.allyEvaluateInput(input, inputText))
Evaluate a collection of checkboxes (cy.allyEvaluateCheckboxes(selectorArray))
Evaluate a modal window (cy.allyEvaluateModalWindow(modalTrigger, modalElement, modalCloseElement, triggerKey = ‘Enter’))
Check expected internal focusable count (cy.hasFocusableCount(selector, count))
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
// 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
// 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.
// 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
// 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:
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:
// 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:
// 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
// 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
// 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
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.
cy.typeInIfDataExists('#root_foo', data.foo);
Or, lastly only target the input by label and type in the text:
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.
// 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)
// 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.
// *** Review & Submit ***
cy.setCheckboxFromData('[name="privacyAgreementAccepted"]', true);
cy.tabToSubmitForm();
Help and feedback
Get help from the Platform Support Team in Slack.
Submit a feature idea to the Platform.