Let's take a good TodoMVC example made with accessability features included, as every app should have. One of the aspects we need to confirm in our tests is that the user can fully interact with the app without using a mouse. We need to test every feature using the keyboard, as posts like Keyboard Navigation Accessibility Testing suggest.
- Testing
- Completing items
- Deleting the first item
- Editing an item
- Tab through filters
- Test the filtered views
- Avoiding clicks
- Testing smaller features
- Publishing the site
- See also
Our example app is at dylanb.github.io/todomvc with the source code available in dylanb/todomvc repo. Here is me adding a todo item and then marking it completed using the keyboard keys.
Adding and completing a todo using the keyboard
Every action can be done using the keyboard. Every element of the application can get its focus by pressing the Tab key. We can focus on the previous item by pressing Shift+Tab keys together. In the video below I rotate through the items and the buttons using the keyboard Tab key before going back to the input field.
Navigating through the application using Tab and reverse Tab
Testing #
To learn the basics of keyboard testing for web applications, read the WebAIM: Keyboard Accessibility article.
Let's confirm the application does in fact work using the keyboard commands without using the mouse button. To write the tests I will use Cypress.io with cypress-real-events plugin for sending the real "Tab" event to the browser. Tip: read my blog post Cypress Real Events Plugin for more information about this awesome plugin.
1 | npm i -D cypress cypress-real-events |
๐ You can find these tests and the fixed application in the repo bahmutov/test-todomvc-using-keyboard. You can use my version of the application at https://glebbahmutov.com/test-todomvc-using-keyboard/.
Let's write a test to make sure we can focus the input field by pressing Tab
.
1 | // load intelligent code completion for Cypress and the plugin |
We are using .realClick()
to first focus the test runner on the application, then send .realPress('Tab')
. The application should set focus on the input field, which we check using the cy.focused().should('have.id', 'new-todo')
assertion.
The first Tab focuses on the input field
While the element has focus, let's confirm its label is visible. Let's look at the HTML markup.
We can add to our test a new command with an explicit visibility assertion.
1 | // the first tab should bring us to the input element |
Note that Cypres time-traveling debugger does not reset the ":focus" when recreating the DOM snapshot. Thus if we go back to the cy.contains
command, the DOM snapshot restores the regular element.
Going back to the command shows the DOM snapshot without the element's focus
Thus to make the tests more explicit and the elements clearly positioned, let's add a wait command.
1 | // confirm the label is visible |
Let's add a few Todo items by typing into the focused input field.
1 | cy.focused().type('code app{enter}').type('ensure a11y{enter}') |
The test is adding two todo items
Note: I have used the cy.wait(1000, noLog)
commands through the test to make sure the videos clearly show the focused elements.
Completing items #
To complete an item the user needs to focus on the "Complete" item button by pressing the Tab key. We could write a new test or we could continue extending the same "works using the keyboard only" end-to-end test with a log message to clearly identify what the test is doing. Again, for the video I have added 1 second pauses after some test commands.
1 | cy.log('**complete the first item**') |
The test navigates using Tab and completes the first todo
Notice how clearly the application shows the focused element using the element outline. Please do not remove the outline.
Deleting the first item #
Let's navigate to the Delete element and remove the item.
1 | cy.log('**delete the first item**') |
The test deletes the first todo
Editing an item #
After deleting an item, we have two more application features to test. First, let's edit an item.
1 | cy.log('**edit an item**') |
The steps show the application changing the text of the todo item.
Tab through filters #
From editing an item, let's navigate through the filters at the bottom of the page.
1 | cy.log('**tab through filters**') |
Test the filtered views #
Finally, let's confirm the filtered views work.
1 | cy.focused().should('have.text', 'Completed').wait(1000, noLog).click() |
The test correctly navigates through the filtered views
Avoiding clicks #
"Wait!", you might say. You used the command .click() in the test - which is a mouse click event. This is a wrong command to use during the keyboard-only test.
Yes, I should have used the .realPress
command only. To avoid accidentally using cy.click()
let's remove this command.
1 | Cypress.Commands.overwrite('click', () => { |
Our current test fails.
Instead of cy.click
we should use cy.realPress('Space')
or .realPress('Enter')
command. This is where we find a problem - we cannot edit an existing item using the keyboard only - we need to click the item to start editing it. The application does not listen to the "Enter" key when focused on the existing Todo item.
1 | cy.focused() |
The application does not let us start editing an item using the keyboard
Ok, time to fork the application and do some coding. I have copied the application into public folder and added a static server and start-server-and-test utility.
1 | npm i -D serve start-server-and-test |
We need to server the public
folder and open Cypress after the port 5000 responds. These are the new NPM package script commands.
1 | { |
Normally I would use npm run dev
to develop the code locally white testing it, see the post How I Organize my NPM Scripts.
To fix the individual item edit, I added the following code in todoKbd.js directive:
1 | .on('keydown', function (event) { |
The full test below now should pass
1 | // load intelligent code completion for Cypress and the plugin |
The full keyboard-only test passes
Testing smaller features #
When we are done with the longer test, I like to see if there are smaller application features that need to be tested. Usually I suggest using code coverage as a guide to finding the missed features. In our case, the application has no built step, so using the code coverage is tricky. Let's simply look at the code to find what features the application has but we are not testing yet.
Cancel edits
The application code has the following fragment:
1 | if (event.keyCode === ESCAPE_KEY) { |
When we are editing an existing item in the list, if we press the Escape key, the edits should be reverted. Let's test it.
1 | it('cancels edit on escape', () => { |
Testing if the app reverts an edit on Escape press
Clear completed todos
Another feature we need to test is clearing the completed items using a button press. The button only appears when there are completed items.
We need to clear the completed items using the keyboard navigation
To speed up this test we can avoid entering the todo items through the page. The application stores the todos in the localStorage
object.
Todo items stored by the application
Cypress automatically clears the local storage before each test. We can set the items in the local storage when visiting the page to start the application with a few items before testing completing them.
1 | it('completes all todos', () => { |
The test starts almost instantly, since the data is already there. Next, let's verify that clearing the 2 completed items works.
1 | // the focus should be set on the "All" filter link |
The test passes
Toggle all
We also need to make sure we can toggle all todos using the button next to the input element.
Again, we need a couple of todos to start with. We can move the todos
array from the previous test into its own JSON file. Instead of using the cy.fixture command, we can import the JSON file into the spec file, see the post Import Cypress fixtures.
1 | import todos from './todos.json' |
When toggling all todos, we need to count which items are completed. First, there are two such items.
1 | cy.get('#todo-list li').should('have.length', 3) |
The initial focus is set on the "All" filter. We need to press "Shift+Tab" 10 times to navigate back to the #toggle-all
element. We can do this by calling the cy.realPress
via Lodash _.times
function bundled in Cypress.
1 | // the focus should be set on the "All" filter link |
Let's press the element and see what happens.
1 | cy.focused() |
What happens if we press the element again? All items will be active again.
1 | // if we press the "toggle-all" again, all items will become incomplete |
The entire test is a joy to watch.
Testing the toggle all element
Publishing the site #
I have added continuous integration to my repository using GitHub Actions. Here is the entire cy.yml file.
1 | name: ci |
You can see the automatically deployed app at https://glebbahmutov.com/test-todomvc-using-keyboard/ and verify the keyboard input really works. If you find a bug, let me know, as I want to make sure the tests are complete.
See also #
On the personal level, I feel like the above keyboard tests should be executed against every TodoMVC example included in the todomvc.com site.