- Published on
Automated <form> Accessibility Testing With @testing-library
- Authors
- Name
- Tim Damen
- @timdamen_io
In this guide I will show how to automatically test certain parts of form accessibility with @testing-library
. This tutorial is an extension on a talk I gave at FrontendNation. If you did not attend the talk, you can still follow along without any problems. But if you would like to see the slides, then follow this link to open the slides. I will be using Vue.js in this tutorial, but the principles can be applied to any front-end framework.
Table of Contents
Why you should take good care of form accessibility
In general making sure people with disabilities can use your app/website is always the best way to go. If you are building something on the web, making sure it's accessible gives you an advantace over non-accessible websites because, on an accessible website everyone is able to buy your product or use your service. So this gives you a larger audience to interact with your app.
It's about energy đ and time â°
People with disabilities are often faced with barriers when browsing the web. This can be very frustrating and time consuming. By making sure your form is accessible, you can save people a lot of time and energy. Time and energy they can spend on other more meaningfull things, not having to puzzle their way trough an inaccessible form.
@testing-library
đ
What is @testing-library
is a set of utilities for testing JavaScript code. It is a simple and complete testing library that encourages using semantic HTML. It intergrates really well with populair unit testing frameworks like Jest and Vitest and, one of the guiding principles of the Testing Library APIs is that they should enable you to test your app the way your users use it, including through accessibility interfaces like screen readers.
Requirements
Some basic knowledge of Vue.js and Jest/Vitest is required to follow along with this guide. Other than that you will need to have the following installed on your machine:
- At the time of writing this guide, I'm using Node v20.9.0 and npm v10.1.0.
- An editor of your choice, I'm using Visual Studio Code.
- A terminal to run commands, I'm using the default MacOs terminal.
- A browser to view the project, I'm using Google Chrome.
Project setup
Let's start by creating a new Vue.js project. Open your terminal and run the following command:
1. create Vue.js project
npm create vue@latest
This command will install and execute create-vue, the official Vue project scaffolding tool. You will be presented with prompts for several optional features such as TypeScript and testing support. Now please select the following options:
â Project name: <your-project-name>
â Add TypeScript? No
â Add JSX Support? No
â Add Vue Router for Single Page Application development? No
â Add Pinia for state management? No
â Add Vitest for Unit testing? Yes
â Add an End-to-End Testing Solution? No
â Add ESLint for code quality? Yes
â Add Prettier for code formatting? Yes
â Add Vue DevTools 7 extension for debugging? (experimental) No
Scaffolding project in ./<your-project-name>...
Done.
Important is to select Yes
for the Add Vitest for Unit testing?
option. This will add Vitest to the project. Vitest is a testing framework that is inspired by Jest and is a good fit for Vue.js projects. It is also the testing framework that we will be using in this guide. We will use Vitest togther with @testing-library
to test the accessibility of a form.
@testing-library
packages
2. install Now we need to install the @testing-library
packages. Navigate to the root of the project and run the following command in your terminal:
npm install @testing-library/jest-dom @testing-library/user-event @testing-library/vue --save-dev
vitest.config.js
file
3. update The last thing we need to do for the setup is to add the globals: true
property to the vitest.config.js
file. This will make it so a cleanup is done by @testing-library
after each test. Open the vitest.config.js
file and add the following code:
...
defineConfig({
test: {
environment: 'jsdom',
+ globals: true,
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url))
}
})
...
Template and Styling
In this section I will not go in to much detail about the template and styling of the form. I will provide you with the code and highlight a few things that are worth mentioning for the accessibility of the form. I'm also sure that not every part of the Vue.js template and the styling is top notch, but that is not the focus of this tutorial.
You can check out a live demo of the final Vue.js app we will create and the complete codebase for the Vue.js app on Github.
Open the src/App.vue
file and replace the content with the following code:
âšī¸ Click to open the App.vue
code samples
<template>
<main>
<h1 class="card-title">Contact page</h1>
<p class="card-hint">
<span aria-hidden="true">*</span>
Fill the required fields
</p>
<div class="card">
<form novalidate @submit="handleSubmit">
<div class="field">
<label for="email" class="field-label" :class="{ error: emailError !== '' }">
Email
<span aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
aria-required="true"
class="field-input"
aria-describedby="emailError"
v-model="email"
:aria-invalid="emailError !== ''"
@blur="validateFieldEmail(email)"
/>
<span class="field-hint">
<p class="field-error" id="emailError" role="alert">
{{ emailError }}
</p>
</span>
</div>
<div class="field">
<label for="phone" class="field-label" :class="{ error: phoneError !== '' }">
Phone number
<span aria-hidden="true">*</span>
</label>
<input
id="phone"
type="tel"
class="field-input"
aria-required="true"
aria-describedby="phoneError phoneHint"
:aria-invalid="phoneError !== ''"
v-model="phone"
@blur="validateFieldPhone(phone)"
/>
<span class="field-hint">
<p class="field-error" id="phoneError" role="alert">
{{ phoneError }}
</p>
<p id="phoneHint">Hint: Phone number must start with 06.</p>
</span>
</div>
<p
class="form-feedback"
:class="{ error: formFeedbackError, result: formFeedback }"
role="alert"
>
{{ formFeedback }}
</p>
<button type="submit" class="button">Save</button>
</form>
</div>
</main>
</template>
...
<script setup>
import { ref } from 'vue'
const email = ref('')
const phone = ref('')
const emailError = ref('')
const phoneError = ref('')
const formFeedback = ref('')
const formFeedbackError = ref(false)
const validateFieldEmail = (email) => {
const isEmpty = email === ''
if (isEmpty) {
emailError.value = 'Error: Email is required.'
} else {
const isEmailValid = /\S+@\S+\.\S+/.test(email)
emailError.value = isEmailValid ? '' : 'Error: Email must contain @ and . symbols.'
}
}
const validateFieldPhone = (phone) => {
const isEmpty = phone === ''
if (isEmpty) {
phoneError.value = 'Error: Phone number is required.'
} else {
const isPhoneValid = /^06+/.test(phone)
phoneError.value = isPhoneValid ? '' : 'Error: Phone number must start with 06.'
}
}
const handleSubmit = (e) => {
e.preventDefault()
validateFieldEmail(email.value)
validateFieldPhone(phone.value)
if (emailError.value || phoneError.value) {
const errorCount = (emailError.value ? 1 : 0) + (phoneError.value ? 1 : 0)
formFeedbackError.value = true
formFeedback.value =
errorCount === 1
? `Error: Failed to save because ${errorCount} field is invalid.`
: `Error: Failed to save because ${errorCount} fields are invalid.`
} else {
formFeedbackError.value = false
formFeedback.value = 'Saved with success. â
'
}
}
</script>
...
<style>
html {
font-size: 80%;
--theme-width: 650px;
--theme-text_0: #333;
--theme-text_1: #6e6e6e;
--theme-bg_0: #f7f2ed;
--theme-bg_1: white;
--theme-primary: blue;
--theme-secondary: orange;
--theme-focus-shadow: var(--theme-bg_0) 0 0 0 2px, var(--theme-secondary) 0 0 0 4px;
}
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: var(--theme-bg_0);
color: var(--theme-text_0);
font-family: 'arial', 'sans-serif';
font-size: 1.6rem;
font-weight: 300;
box-sizing: border-box;
line-height: 1.5;
}
body * {
box-sizing: inherit;
}
.card {
position: relative;
max-width: var(--theme-width);
min-width: 500px;
padding: 24px 16px;
border: 1px solid var(--theme-primary);
background-color: var(--theme-bg_1);
box-shadow: 2px 2px lightgray;
}
.card-title {
font-size: 1.8rem;
margin: 0;
font-weight: 600;
}
.card-hint {
font-size: 1.3rem;
margin: 0;
}
.field {
margin-bottom: 16px;
}
.field-label {
display: block;
line-height: 1;
margin-bottom: 4px;
}
.field-label.error {
color: red;
}
.field-input {
max-width: 15rem;
min-height: 3rem;
border: none;
border: 1px solid var(--theme-text_1);
font-size: 1.6rem;
}
.field-input:focus:not(:focus-visible) {
outline: none;
}
.field-input:focus-visible {
outline: none;
box-shadow: var(--theme-focus-shadow);
}
.field-hint {
font-size: 1.4rem;
margin-top: 4px;
color: var(--theme-text_1);
}
.field-error {
margin-top: 4px;
font-size: 1.4rem;
color: red;
}
.form-feedback {
padding: 5px;
padding: 0 5px 0 5px;
margin-bottom: 10px;
}
.form-feedback.result {
border: solid 2px green;
}
.form-feedback.error {
border: solid 2px red;
}
.button {
display: inline-block;
cursor: pointer;
font-size: 1.6rem;
padding: 8px 20px;
background-color: var(--theme-primary);
border: none;
color: white;
text-align: center;
}
.button:focus {
outline: none;
box-shadow: var(--theme-focus-shadow);
}
</style>
Let's highlight a few things đī¸
Let's highlight a few things that are worth mentioning for the accessibility of the form and later on for the testing of the form:
...
<div class="field">
<label for="phone" class="field-label" :class="{ error: phoneError !== '' }">
Phone number
<span aria-hidden="true">*</span>
</label>
<input
id="phone"
type="tel"
class="field-input"
aria-required="true"
aria-describedby="phoneError phoneHint"
:aria-invalid="phoneError !== ''"
v-model="phone"
@blur="validateFieldPhone(phone)"
/>
<span class="field-hint">
<p class="field-error" id="phoneError" role="alert">
{{ phoneError }}
</p>
<p id="phoneHint">Hint: Phone number must start with 06.</p>
</span>
</div>
...
From top to bottom:
- The
label
element has afor
attribute that matches theid
of theinput
element. This is important for screen readers and other ATs to know what the input field is for. - The
input
elementtype
attribute is set totel
. This is important for mobile devices to show the correct keyboard layout. Also screen readers and other ATs can use this information to provide the user with the correct information. - The
input
element has anaria-required
attribute set totrue
. This is for screen readers and other ATs to know that the input field is required. - The
input
element has anaria-describedby
attribute that references theid
s of two differentp
elements with theid
ofphoneError
andphoneHint
. This is important for screen readers and other ATs to know where to find the error message and hint message for important extra information or state of the input element. - The
input
element has anaria-invalid
attribute that is set to a dynamic value. This is for screen readers and other ATs to know if the input field is in an invalid state. - The
p
element withclass="field-error"
has arole
attribute set toalert
. This is for screen readers and other ATs to know that this element is an alert message. The moment the content inside of this element changes, the screen reader will read the content of this element.
All of the above mentioned points make the form more accessible for a lot of users. But what if I told you that you can automatically test all of this with @testing-library
? Let's dive into the testing part of the form.
Testing
We are going to create a so called component test for the form. A component test is a test in between a unit test and end-to-end test. A component test checks that your component mounts, renders, can be interacted with, and behaves as expected. These tests import more code than unit tests, are more complex, and require more time to execute. We will test the form with the @testing-library
utilities.
1. Setup
First we need to create a test file. Create a new file in the src
folder called App.spec.js
. Now open the file, the first thing we need to do is create a setup()
function. This helper function will render the component and return the interactive html elements that we want use in our tests.
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import '@testing-library/jest-dom/vitest'
import userEvent from '@testing-library/user-event'
import App from './App.vue'
function setup() {
const utils = render(App)
const emailInput = screen.getByRole('textbox', { name: 'Email' })
const phoneInput = screen.getByRole('textbox', { name: 'Phone number' })
const submitButton = screen.getByRole('button', { name: 'Save' })
return {
emailInput,
phoneInput,
submitButton,
...utils
}
}
describe('Contact form - App.vue', () => {
it('renders properly', () => {
const { container } = setup()
expect(!!container.parentNode).toBeTruthy()
})
})
In the setup()
function we use the render()
function from @testing-library/vue
to render the App
component. We then use the screen
object to get the interactive html elements we want to use in our tests. We get the emailInput
and phoneInput
elements by their role
and name
attributes. We get the submitButton
element by its role
and name
attributes. We then return the interactive html elements and the utils
object from the render()
function. More information on the queriering of elements like types of queries and rules on how to use them can be found here.
2. input labels
The first test we are going to write is to check if the input labels are present in the form. The importants of this test is sortly mentioned in the template and styling section.
...
describe('Contact form - App.vue', () => {
...
it('has email and phone number with correct labels', async () => {
// call the setup function and get the emailInput and phoneInput elements
const { emailInput, phoneInput } = setup()
expect(emailInput).toHaveAccessibleName('Email')
expect(emailInput).not.toHaveAccessibleName('Email*')
expect(phoneInput).toHaveAccessibleName('Phone number')
expect(phoneInput).not.toHaveAccessibleName('Phone number*')
})
})
In the test we use the toHaveAccessibleName()
matcher to check if the emailInput
and phoneInput
elements have the correct label. The not.toHaveAccessibleName()
matcher checks if the element does not have the name with an asterisk because we have hidden the asterisk with aria-hidden
. The toHaveAccessibleName()
matcher is a custom matcher that is provided by the @testing-library/jest-dom
package. This package provides custom matchers for asserting on the state of the DOM. More information on the custom matchers can be found here.
3. input types
The second test we are going to write is to check if the input types are correct. The importants of this test is sortly mentioned in the template and styling section.
...
describe('Contact form - App.vue', () => {
...
it('has email and phone number as the correct input types', async () => {
const { emailInput, phoneInput } = setup()
expect(emailInput).toHaveAttribute('type', 'email')
expect(phoneInput).toHaveAttribute('type', 'tel')
})
})
In the test we use the toHaveAttribute()
matcher to check if the emailInput
and phoneInput
elements have the correct type
attribute.
4. input required
The third test we are going to write is to check if the input fields are required. The importants of this test is sortly mentioned in the template and styling section.
...
describe('Contact form - App.vue', () => {
...
it('has email and phone number as required form elements', async () => {
const { emailInput, phoneInput } = setup()
expect(emailInput).toBeRequired()
expect(phoneInput).toBeRequired()
})
})
In the test we use the toBeRequired()
matcher to check if the emailInput
and phoneInput
elements are required.
5. input hint
The fourth test we are going to write is to check if the phone number input field has an accessible hint. The importants of this test is sortly mentioned in the template and styling section.
...
describe('Contact form - App.vue', () => {
...
it('has a accessible hint for the phone number input', async () => {
const { phoneInput } = setup()
expect(phoneInput).toHaveAccessibleDescription(
expect.stringMatching('Hint: Phone number must start with 06.')
)
})
})
In the test we use the toHaveAccessibleDescription()
matcher to check if the phoneInput
element has an accessible hint. If we look closly you can see that we use the expect.stringMatching()
matcher to check if the hint contains the correct text. This is done because there is an other p
element with the id
of phoneError
that also connect to the phoneInput
element, but this error message is correctly empty at this moment, this fact makes it so that the toHaveAccessibleDescription()
matcher will fail if we don't use the expect.stringMatching()
matcher, because the empty <p id="phoneError">
returns an empty line.
6. input invalid and error message
The fifth and sixth test we are going to write is to check if the input fields are invalid and show an error message when the input is empty. The importants of this test is sortly mentioned in the template and styling section.
...
describe('Contact form - App.vue', () => {
...
it('shows an error message when email is empty', async () => {
const { emailInput } = setup()
await userEvent.click(emailInput)
await userEvent.tab()
expect(emailInput).toBeInvalid()
expect(emailInput).toHaveAccessibleDescription('Error: Email is required.')
})
it('shows an error message when phone number is empty', async () => {
const { phoneInput } = setup()
await userEvent.click(phoneInput)
await userEvent.tab()
expect(phoneInput).toBeInvalid()
expect(phoneInput).toHaveAccessibleDescription(
expect.stringMatching('Error: Phone number is required.')
)
})
})
In the tests we for the first time use the userEvent
object from the @testing-library/user-event
package. This package provides a set of utilities for interacting with the DOM. We use the click()
and tab()
functions to focus the emailInput
and phoneInput
elements. We then use the toBeInvalid()
matcher to check if the emailInput
and phoneInput
elements are invalid. We use the toHaveAccessibleDescription()
matcher to check if the emailInput
and phoneInput
elements have an accessible error message.
7. form feedback
The seventh test we are going to write is to check if the form feedback is shown when the form is invalid.
...
describe('Contact form - App.vue', () => {
...
it('shows an error messages when save is click and form is empty', async () => {
const { submitButton } = setup()
await userEvent.click(submitButton)
const generalErrorMessage = screen.getByText(
'Error: Failed to save because 2 fields are invalid.',
{ selector: 'p' }
)
expect(generalErrorMessage).toBeInTheDocument()
})
})
In this test we use the an userEvent
to click the submitButton
element. We then query the generalErrorMessage
element with the getByText()
function from the screen
object. We then use the toBeInTheDocument()
matcher to check if the generalErrorMessage
element is present in the DOM.
Conclusion
In this guide we have learned how to test certain parts of form accessibility with @testing-library
. We have created a Vue.js app with a form that has a few accessibility features. We have then created a few tests with @testing-library
to check if the form is accessible. We have tested the following parts of the form:
- input labels,
<label>
andfor=""
- input types,
type=""
- input required
aria-required="true"
- input hint
aria-describedby=""
- input invalid and error message
aria-invalid="true"
- form feedback
We have also used the @testing-library/jest-dom
and @testing-library/user-event
packages to help us write the tests. I hope you have learned something new and that you can apply this knowledge to your own projects. If you have any questions or feedback, feel free to reach out to me on Twitter (X).