Accessibility testing with Playwright
Published: | Author: Russ Weakley
Contents
- Introduction
- Playwright vs axe-core
- Try the examples yourself
- Example 1: Every button has an accessible name
- Example 2: Disclosures communicate their expanded state
- Example 3: Every control can be reached with the keyboard
- Example 4: Dialogs manage focus
- Example 5: Form errors are marked and linked
- Example 6: Accessibility checks are reused across a website
- Practical notes
- Further exploration
- Conclusion
Introduction
Automated accessibility testing tools, such as WAVE, axe DevTools and Lighthouse, can identify many accessibility issues.
However, some issues only become apparent after someone interacts with a page, such as opening a dialog, submitting a form or navigating with a keyboard.
Tools like Playwright can automate these interactions and check that they behave as expected.
This resource provides practical examples of using Playwright to test common accessibility requirements. Each example builds on the previous one and includes a simple JavaScript snippet and a test page with working and deliberately broken components. This allows you to see how the tests behave.
Who this is for
- Accessibility specialists and auditors who may never write tests, but advise teams who do.
- Developers new to Playwright who want to learn how to test accessibility-related behaviour.
- Developers already using Playwright, perhaps through Storybook, who want to explore testing interactions.
If you're an accessibility specialist or auditor, you don't need to follow every line of code. Each example starts with the expectation in plain language and shows what happens when the test fails.
Playwright vs axe-core
axe-core is an open-source accessibility testing engine that powers tools such as the axe DevTools browser extension.
axe-core checks pages in their current state. It can identify issues such as missing accessible names, invalid ARIA attributes and poor colour contrast, but cannot perform interactions such as pressing keys, opening dialogs or tracking keyboard focus.
Playwright can automate these interactions in a browser. For example, a test can open a page, press Tab or Enter, click buttons, fill in forms and check what happens next.
Playwright reports are not audit reports
Accessibility audit reports typically document accessibility issues found during an assessment. They may include descriptions of each issue, where it occurs, which accessibility requirements it fails, how severe the issue is, and recommendations for fixing it.
Playwright also produces reports, but these serve a different purpose. They show which automated tests passed or failed, along with information to help developers investigate and fix problems during development. These reports do not provide a comprehensive assessment of a product's accessibility.
For example, a Playwright report might show that a test failed because keyboard focus did not move to a dialog when it opened.
| Comparison | Audit report | Playwright report |
|---|---|---|
| Answers | What's wrong with this page? | Did the expected behaviour happen? |
| Covers | Issues identified during the audit | Only what the tests check |
| Includes | Severity, WCAG mapping, remediation advice | Pass or fail, where it failed, the steps, a snapshot |
| Written for | Teams, managers, stakeholders | Developers working on the code |
| When | At a point in time | Whenever the tests run |
Catching accessibility issues after code changes
Accessibility audits assess a website at a particular point in time. Between audits, developers may make hundreds of changes to the code. Some of these changes could accidentally reintroduce accessibility issues that were previously fixed.
Playwright offers a way to help prevent this through regression testing. Developers can write automated tests for specific accessibility requirements and include them in their development process. These tests can run whenever code changes are proposed, helping teams identify problems before those changes are released.
For example, a typical development process might look like this:
- A developer proposes a change to the code.
- The build system automatically runs the tests.
- If all tests pass, the change can proceed.
- If a test fails, the developer can investigate and fix the problem before the change is merged.
Tests only answer the questions you ask
A passing test can only confirm that a specific expectation was met. For example, a test might confirm that every button has an accessible name, but it cannot determine whether those names are meaningful.
Automated tests don't prove a component is accessible, and they don't replace accessibility audits or testing with real people, including people who use assistive technologies every day.
Try the examples yourself
You can read this resource without running any tests. However, if you'd like to see the tests pass and fail for yourself, everything you need is provided.
Every example has its own page on the playground site. Each page includes working components and at least one deliberately broken component, with an explanation of what's wrong.
It's worth opening each page and trying the components yourself before running the tests. In many cases, the accessibility issue won't be immediately obvious. That's part of what these tests are designed to demonstrate.
The tests
All six tests are available in the playground repository. The code matches the examples in this resource.
To run the tests, you'll need the test files on your computer and Playwright installed. You can either:
- Clone or download the repository to get all the tests and a ready-made Playwright project.
- Copy individual tests from this resource into the
testsfolder of your own Playwright project.
The tests run on your computer and open the published playground pages in a browser.
Setting up Playwright
If you haven't used Playwright before, you'll need to install Node.js and follow the Playwright installation guide.
If you've cloned the repository, the README includes instructions for getting started.
Two ways to run a test
UI mode is useful while you're exploring. It opens a window where you can run individual tests, explore each step and see the results:
npx playwright test --ui
By default, UI mode only runs the first browser in the configuration, which is Chromium in this project. You can select the others in its Projects filter.
The terminal runs tests in Chromium, Firefox and WebKit, and generates an HTML report you can open afterwards. This is similar to how tests run on a build server:
npx playwright test tests/disclosures.spec.js
To open the report from the last terminal run:
npx playwright show-report
Understanding test failures
Every test in this resource is designed to identify at least one deliberately broken component. This means you should expect some tests to fail.
When a test fails, look at the results to understand what went wrong and why.
Example 1: Every button has an accessible name
Every button needs an accessible name so assistive technologies can identify it. Icon-only buttons are a common source of problems. The icon may be visually clear, but without an accessible name, screen reader users may not know what the button does.
What we will test
Any button without an accessible name.
The expectation: Every button on the page has an accessible name.
axe-core already checks for this. We're starting with a simple example to introduce an approach used throughout this resource: find all elements of a particular type and check each one.
The test script
import { test, expect } from '@playwright/test';
// The playground page
const pageUrl = 'https://intopia.github.io/playwright-testing/playground/buttons.html';
test('every button has an accessible name', async ({ page }) => {
// Open the playground page
await page.goto(pageUrl);
// Find every button on the page
const buttons = await page.getByRole('button').all();
// Check each one has an accessible name (at least one character)
for (const button of buttons) {
// Grab the button's markup so a failure shows exactly which button it was
const markup = await button.evaluate(el => el.outerHTML);
// expect.soft keeps going after a failure, so every broken button is reported
await expect.soft(
button,
'This button has no accessible name:\n' + markup
).toHaveAccessibleName(/.+/);
}
});
In plain language, the test:
- Opens the page.
- Finds every button.
getByRole('button')finds elements with the button role, and.all()returns them as a list. - Checks each button. The loop checks buttons one at a time.
toHaveAccessibleName(/.+/)checks that each has a name containing at least one character. - Reports every failure.
expect.softallows the test to continue checking other buttons after a failure, rather than stopping at the first one. - Identifies the problem. The test includes the failing button's HTML markup in the report, making it easier to locate and fix.
The loop means the test works regardless of how many buttons are on the page. If more buttons are added, they'll be checked automatically.
The later examples build on this approach, checking multiple elements and reporting which ones failed and why.
A word about await
You'll see await throughout this test and the examples that follow. Playwright's actions and checks take time to complete, such as opening a page, pressing a key or waiting for something to appear. await tells the test to wait for each operation to finish before moving on. The async keyword at the start of the test allows await to be used inside it.
Forgetting await is a common mistake when learning Playwright. Without it, a test may continue or even finish before a check has completed. This can sometimes result in a test passing when it should fail.
If a test passes unexpectedly, check for missing await statements.
Try it yourself
Video description
The video has no audio. It shows:
- View the HTML example page. An accessible name bookmarklet is run on the buttons playground page, and each of the five buttons is reached using Tab.
- The fifth button has no accessible name. The bookmarklet shows a red badge on the fifth button reading "No accessible name".
- Run the test in terminal. When the test finishes, the HTML report opens automatically in the browser.
- Review the HTML report. The report shows the test failed in Chromium, Firefox and WebKit. The Chromium result is opened to show the error message, including the markup of the button that failed.
- Review the errors in terminal. The same three failures are shown in the terminal.
The buttons playground page has five buttons. Four have accessible names. One is an icon-only button with no name.
Run the test:
npx playwright test tests/buttons.spec.js
The test should fail, with a message that starts like this:
Error: This button has no accessible name:
<button type="button">
<svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
</button>
expect(locator).toHaveAccessibleName(expected) failed
Locator: getByRole('button').nth(4)
Expected pattern: /.+/
Received string: ""
The first part is the custom message showing the HTML markup of the button that failed. The rest is Playwright's report, showing that it expected an accessible name containing at least one character but found an empty string.
The nth(4) identifies the fifth button on the page, because Playwright starts counting from zero.
Fixing the button
You can't edit the published playground page, but you can edit a local copy. Clone the playground repository, open playground/buttons.html and add aria-label="Menu" to the broken button.
Then update the pageUrl line in your test to point to the local file:
const pageUrl = 'file://' + process.cwd() + '/playground/buttons.html';
You don't need a web server for this. file:// opens the page directly from your computer, while process.cwd() refers to the folder where you run the test. Make sure you run it from the repository folder.
Run the test again. It should now pass, confirming that the test can detect the issue and recognise when it's been fixed.
You can use the same approach with the other examples to explore what happens when components are fixed or broken.
Limitations of this test
This test checks that every button has an accessible name containing at least one character. However, it cannot determine whether those names are meaningful. A button named "button" or "click here" would still pass, so the quality of accessible names needs to be checked by a person.
Example 2: Disclosures communicate their expanded state
A disclosure is a button that shows and hides content, such as an accordion panel or a "Read more" section. The button's aria-expanded attribute tells assistive technologies whether the content is currently expanded ("true") or collapsed ("false").
What we will test
A disclosure that opens and closes visually, but doesn't update its aria-expanded attribute.
For example, a sighted mouse user may not notice anything wrong. However, a screen reader user could activate the disclosure and still hear "collapsed", even though the content is now visible.
The expectation: When a disclosure opens, aria-expanded changes to "true". When it closes, the value changes back to "false".
An axe-core scan of the initial page may not identify this issue because aria-expanded="false" is valid when the disclosure is closed. The problem only becomes apparent after someone activates the button, which is why testing the interaction is important.
The test script
import { test, expect } from '@playwright/test';
// The playground page
const pageUrl = 'https://intopia.github.io/playwright-testing/playground/disclosures.html';
test('every disclosure keeps aria-expanded in sync with its content', async ({ page }) => {
await page.goto(pageUrl);
// Find every disclosure button: any button that controls another element
const buttons = await page.locator('button[aria-controls]').all();
for (const button of buttons) {
// The button's visible text, so failures say which disclosure it was
const name = (await button.textContent()).trim();
// Find the content this button controls, using its aria-controls value
const panelId = await button.getAttribute('aria-controls');
const panel = page.locator('#' + panelId);
// OPEN: press Enter on the button
await button.press('Enter');
await expect.soft(panel, name + ': content should be visible when opened').toBeVisible();
await expect.soft(button, name + ': aria-expanded should be "true" when opened').toHaveAttribute('aria-expanded', 'true');
// CLOSE: press Enter again
await button.press('Enter');
await expect.soft(panel, name + ': content should be hidden when closed').toBeHidden();
await expect.soft(button, name + ': aria-expanded should be "false" when closed').toHaveAttribute('aria-expanded', 'false');
}
});
This test builds on the loop introduced in Example 1, with three new concepts:
page.locator('button[aria-controls]')uses a CSS selector to find buttons with anaria-controlsattribute. In this example, these are the disclosure buttons.button.press('Enter')focuses the button and presses Enter to activate it. However, this doesn't check whether someone can reach the button using Tab. We'll explore that in Example 3.- Two checks after each interaction. The test checks whether the content is visible or hidden, and whether
aria-expandedreflects that state. Both checks must pass when the disclosure opens and closes.
Try it yourself
The disclosures playground page has four disclosures. Three work correctly. The fourth, "Gift wrapping", shows and hides its content but doesn't update aria-expanded.
Try them with a mouse first. All four appear to work correctly, so the accessibility issue may not be obvious.
Then run the test:
npx playwright test tests/disclosures.spec.js
The test should report a failure for the "Gift wrapping" disclosure:
Error: Gift wrapping: aria-expanded should be "true" when opened
Expected: "true"
Received: "false"
The results also show that the content opens and closes correctly. Only the aria-expanded value is incorrect, demonstrating how Playwright can identify an issue that isn't visually apparent.
Limitations of this test
This test checks that pressing Enter opens and closes each disclosure, and that aria-expanded updates correctly.
It doesn't check other interactions, such as pressing Space or clicking with a mouse. It also doesn't confirm what a screen reader announces.
The test uses aria-controls to identify each disclosure's content. If your disclosures are built differently, you may need to adjust the test.
Example 3: Every control can be reached with the keyboard
Keyboard users often navigate through interactive elements using the Tab key. Links, buttons and form controls should generally be reachable this way. If a control cannot receive keyboard focus, some people may be unable to use it.
What we will test
An interactive control that cannot be reached using Tab.
For example, a div with role="button" and a click handler, but no tabindex. It has a role, an accessible name and works with a mouse. However, keyboard users cannot reach it using Tab.
The expectation: Every link, button and form control on the page can be reached by pressing Tab.
axe-core cannot simulate keyboard navigation on its own, so it may not identify this problem. The broken control on our playground page has a valid role and an accessible name, but cannot be reached using Tab.
The test script
import { test, expect } from '@playwright/test';
// The playground page
const pageUrl = 'https://intopia.github.io/playwright-testing/playground/keyboard.html';
test('every interactive control can be reached with the Tab key', async ({ page, browserName }) => {
// On macOS, Playwright's WebKit follows the system keyboard navigation setting,
// where Tab skips links by default. That would make this test fail for reasons
// unrelated to the page, so skip it there.
test.skip(browserName === 'webkit', 'WebKit skips links with Tab by default');
await page.goto(pageUrl);
// The controls we expect keyboard users to reach:
// anything exposed as a link, button, text field, checkbox or select
const controls = await page
.getByRole('link')
.or(page.getByRole('button'))
.or(page.getByRole('textbox'))
.or(page.getByRole('checkbox'))
.or(page.getByRole('combobox'))
.all();
// Press Tab through the page. Each time, mark whatever has focus.
// (We press Tab twice as many times as there are controls, to be safe.)
for (let i = 0; i < controls.length * 2; i++) {
await page.keyboard.press('Tab');
await page.evaluate(() => document.activeElement.setAttribute('data-reached', ''));
}
// Check every control was marked
for (const control of controls) {
const markup = await control.evaluate(el => el.outerHTML);
await expect.soft(
control,
'Could not reach this control with the Tab key:\n' + markup
).toHaveAttribute('data-reached', '');
}
});
This test compares the controls on the page with those that can be reached using the Tab key.
- Find the controls that should be reachable. The
.or()method combines different roles, including links, buttons, text fields, checkboxes and comboboxes. This includes the broken button because it hasrole="button". - Move through the page using Tab. Each time an element receives focus, the test adds a
data-reachedattribute to mark it as visited. This attribute is added to the page during the test. - Check which controls were reached. The test checks each control for the
data-reachedattribute. Any control without it was not reached during the test.
Try it yourself
The keyboard playground page has three navigation links, a text field, a select, a checkbox and a button, all reachable using Tab. It also has a "Download brochure" control that cannot be reached this way.
Try navigating through the page using Tab. You'll notice that the "Download brochure" control is skipped.
Then run the test:
npx playwright test tests/keyboard.spec.js
The test should report one failure in Chromium and Firefox:
Error: Could not reach this control with the Tab key:
<div role="button" class="fake-button" id="download">Download brochure</div>
WebKit will be skipped, for reasons explained in the next section.
Why skip WebKit?
Without this exception, the test also fails on the three navigation links. This isn't a problem with the page itself. By default, Safari on macOS skips links when navigating with Tab. Users can enable "Press Tab to highlight each item" in Safari's settings, or use Option + Tab to reach links.
Playwright uses its own browser installations rather than the browsers installed on your computer, so they may behave differently. Playwright's WebKit doesn't follow Safari's keyboard navigation setting. Instead, it follows the macOS "Keyboard navigation" setting in System Settings > Keyboard (Playwright issue #41808). This means the same test may produce different results on different computers.
When interpreting automated test results, it's important to remember that a failure may be caused by browser behaviour rather than a problem with the page.
Limitations of this test
This test checks whether each identified interactive control receives keyboard focus when navigating with Tab.
It doesn't check whether the focus order is logical or whether the focus indicator is visible.
It also only checks elements with the roles included in the test. For example, a div with a click handler but no interactive role wouldn't be included. Identifying these kinds of issues generally requires manual testing.
Example 4: Dialogs manage focus
When a modal dialog opens, keyboard and screen reader users need focus to move into it. When it closes, focus should generally return to the element that opened it, usually a button.
Where focus moves when the dialog opens depends on the content and purpose of the dialog. In this example, focus moves to the dialog's heading.
Moving focus directly to the first control, such as a close button or form field, may not give screen reader users enough context. Focusing the heading allows them to hear what the dialog is about before interacting with its content.
The ARIA Authoring Practices allow for this approach in some situations, and other approaches may also be appropriate. The important thing is to decide where focus should move and test that it behaves as expected.
What we will test
Two things can go wrong: focus doesn't move to the heading when the dialog opens, or it doesn't return to the button when the dialog closes.
In the broken playground example, focus is lost when the dialog closes. This leaves keyboard users without a visible focus indicator, making it difficult to know where they are on the page. In Chrome, pressing Tab may even move focus into the browser's toolbar rather than to another element on the page.
The expectation: When the dialog opens, focus moves to its heading. When it's closed using Escape, focus returns to the button that opened it.
These issues only become apparent when someone interacts with the dialog, so a standard axe-core scan of the initial page won't detect them.
The test script
import { test, expect } from '@playwright/test';
// The playground page
const pageUrl = 'https://intopia.github.io/playwright-testing/playground/dialogs.html';
test('every dialog focuses its heading on open and returns focus on Escape', async ({ page }) => {
await page.goto(pageUrl);
// Find every button that opens a dialog
const triggers = await page.locator('button[aria-haspopup="dialog"]').all();
for (const trigger of triggers) {
const name = (await trigger.textContent()).trim();
// OPEN: press Enter on the trigger button
await trigger.press('Enter');
// Find the open dialog (closed dialogs are ignored by getByRole)
const dialog = page.getByRole('dialog');
await expect.soft(dialog, name + ': dialog should be open').toBeVisible();
// Focus should be on the dialog's heading
await expect.soft(
dialog.getByRole('heading'),
name + ': focus should move to the dialog heading'
).toBeFocused();
// CLOSE: press Escape
await page.keyboard.press('Escape');
await expect.soft(dialog, name + ': dialog should close with Escape').toBeHidden();
// Focus should return to the button that opened the dialog
await expect.soft(
trigger,
name + ': focus should return to the button that opened the dialog'
).toBeFocused();
}
});
This test introduces a new approach: checking a sequence of interactions. For each dialog, the test opens it, checks focus, closes it and checks focus again.
- Open the dialog. Press Enter on the button that opens it.
- Check the dialog and focus. Confirm that the dialog is open and its heading has keyboard focus.
- Close the dialog. Press Escape.
- Check the dialog and focus again. Confirm that the dialog has closed and focus has returned to the button that opened it.
The test uses getByRole('dialog') to find the currently open dialog. Because closed dialogs are excluded from the accessibility tree, the test doesn't need to know which dialog belongs to each button.
Try it yourself
The dialogs playground page has four buttons that open native <dialog> elements. Each dialog heading uses tabindex="-1" and autofocus, allowing the browser to move focus to the heading without additional JavaScript.
Three dialogs work correctly. The fourth, "Sign out", loses focus when it closes.
Try each dialog using the keyboard: Tab to the button, press Enter to open it, then Escape to close it. Notice where focus moves at each step.
Then run the test:
npx playwright test tests/dialogs.spec.js
The test should report one failure in each browser:
Error: Sign out: focus should return to the button that opened the dialog
Expected: focused
Received: inactive
All the heading focus checks pass in Chromium, Firefox and WebKit. This demonstrates that the autofocus approach works across all three browser engines in this example.
Limitations of this test
This test checks that focus moves to each dialog's heading when it opens, and returns to the button that opened it when the dialog is closed using Escape.
It doesn't check what happens when the dialog is closed using its own buttons, or whether focus stays within the dialog while it's open. These could be added as further checks. It also doesn't confirm how a screen reader announces the heading, which still needs testing with a screen reader.
Example 5: Form errors are marked and linked
When someone submits a form with errors, the affected fields may display error messages and visual indicators, such as coloured borders.
Screen reader users also need:
- The field identified as invalid, using
aria-invalid="true". - The error message associated with the field, usually using
aria-describedby, so the message can be announced when the user moves to the field.
What we will test
A field that displays an error message but doesn't communicate the error correctly to assistive technologies.
The broken form on our playground page has two examples: one field that doesn't get aria-invalid="true", and another whose error message isn't associated with the field. Both appear visually identical to the working fields.
The expectation: After submitting an empty form, every field displaying an error has aria-invalid="true", and its error message is included in its accessible description.
A scan of the initial page won't identify these issues because the error messages only appear after the form is submitted. Playwright can submit the form and check the resulting state.
The test script
import { test, expect } from '@playwright/test';
// The playground page
const pageUrl = 'https://intopia.github.io/playwright-testing/playground/forms.html';
test('every field with an error is marked invalid and linked to its message', async ({ page }) => {
await page.goto(pageUrl);
// Find every form on the page
const forms = await page.locator('form').all();
for (const form of forms) {
// The form's heading, so failures say which form it was
const formName = (await form.getByRole('heading').textContent()).trim();
// SUBMIT: press Enter on the form's button, with every field left empty
await form.getByRole('button').press('Enter');
// Make sure error messages actually appeared.
// (Without this, a form that showed no errors would quietly pass.)
const visibleErrors = form.locator('.error:visible');
await expect.soft(visibleErrors, formName + ': submitting empty should show error messages').not.toHaveCount(0);
// Check every field that is now showing an error
for (const error of await visibleErrors.all()) {
const errorText = (await error.textContent()).trim();
// Each error's id is the field's id plus "-error", so work back to the field
const fieldId = (await error.getAttribute('id')).replace('-error', '');
const field = page.locator('#' + fieldId);
// The field's label, so failures say which field it was
const fieldLabel = (await page.locator('label[for="' + fieldId + '"]').textContent()).trim();
const name = formName + ' > ' + fieldLabel;
// The field should be marked as invalid...
await expect.soft(field, name + ': should have aria-invalid="true"').toHaveAttribute('aria-invalid', 'true');
// ...and its error message should be part of its accessible description
await expect.soft(field, name + ': error message should be in its accessible description').toHaveAccessibleDescription(errorText);
}
}
});
For every form on the page, the test:
- Submits the form empty, by pressing Enter on its submit button.
- Checks that error messages appear. Without this check, a form that displays no errors could pass because there would be nothing to test.
- Finds the field associated with each error. The test uses the page's naming convention, where the error message's
idmatches the field'sidwith-erroradded. It doesn't usearia-describedbyto find the field because that's one of the things being tested. - Checks two things for each field.
aria-invalid="true"identifies the field as invalid, whiletoHaveAccessibleDescriptionchecks that the error message is included in its accessible description.
The final check is particularly useful. Rather than simply checking whether aria-describedby exists, it checks the field's accessible description. If the error message isn't correctly associated with the field, the test fails.
Try it yourself
The forms playground page has three forms. "Contact us" and "Newsletter" work correctly, while "Book a call" has two fields with accessibility issues.
Try submitting all three forms without entering any information. Visually, they behave the same way.
Then run the test:
npx playwright test tests/forms.spec.js
The test should report two failures in each browser:
Error: Book a call > Name: should have aria-invalid="true"
Error: Book a call > Phone: error message should be in its accessible description
Each field fails a different check. The Name field passes the accessible description check but fails the aria-invalid check. The Phone field passes the aria-invalid check but fails the accessible description check.
This shows how the test can identify two different accessibility issues, even when the fields appear to behave correctly on screen.
Why failing tests take longer
Open this test in the HTML report and look at the steps. The passing checks take a few milliseconds, while the two failing checks take around five seconds each.
This happens because Playwright automatically retries checks for a short period before reporting a failure. This is useful for pages where content may take time to update.
However, it also means that tests with multiple failures can take longer to complete, and may reach Playwright's default 30-second timeout for an entire test.
Adapting the test for other forms
When given a plain text string, toHaveAccessibleDescription expects the entire accessible description to match.
In this example, each field's description contains only its error message. However, many forms also provide instructions or hint text using aria-describedby. For example, a field might have the hint "Use your work email" followed by an error message.
In these cases, the test can check whether the accessible description contains the error message, rather than requiring an exact match:
await expect.soft(field, name + ': error message should be in its accessible description')
.toHaveAccessibleDescription(new RegExp(errorText));
If the error message contains special regular expression characters, such as ., ? or brackets, these must be escaped before creating the expression.
Limitations of this test
This test checks that after submitting an empty form, every field displaying an error is marked as invalid and includes the error message in its accessible description.
It doesn't check other validation errors, such as an incorrectly formatted email address. It also doesn't check focus management, error summaries or what a screen reader announces. The test relies on the page's naming convention to associate error messages with fields, so other forms may require a different approach.
Example 6: Accessibility checks are reused across a website
So far, each example has tested one type of component on a single page. However, real websites contain many pages with different combinations of components.
Rather than writing separate tests for every component on every page, Playwright allows developers to create reusable checks. These checks can then run across multiple pages, making it easier to maintain consistent accessibility testing throughout a website.
What we will test
A small demonstration website with three pages:
- Home: Disclosures and a newsletter dialog. All components work correctly.
- Products: A product list with "Add to cart" buttons. One button cannot be reached using the keyboard, as demonstrated in Example 3.
- Help: FAQ disclosures and a contact form. The Email field is not marked as invalid, as demonstrated in Example 5.
The expectation: Every page meets the relevant accessibility requirements tested in Examples 2 to 5.
The test scripts
This example uses two files.
The first contains the reusable checks. Each check is based on an earlier example, converted into a function that can be used by multiple tests. Here's the structure, with the dialog check shown in full:
// tests/helpers/accessibility-checks.js
import { expect } from '@playwright/test';
// Each check is the test from an earlier example, wrapped in a function.
// The page name is added to every failure message.
// If a page has none of a component, its check simply passes.
// These three are shortened here. Copy the full versions from the repo.
// If you run them as they are, they stop the test with an error,
// rather than passing without checking anything.
export async function checkDisclosures(page, pageName) {
throw new Error('checkDisclosures is a placeholder: copy the full version from the repo');
}
export async function checkKeyboard(page, pageName) {
throw new Error('checkKeyboard is a placeholder: copy the full version from the repo');
}
export async function checkForms(page, pageName) {
throw new Error('checkForms is a placeholder: copy the full version from the repo');
}
// DIALOGS (example 4)
export async function checkDialogs(page, pageName) {
const triggers = await page.locator('button[aria-haspopup="dialog"]').all();
for (const trigger of triggers) {
const name = pageName + ' > ' + (await trigger.textContent()).trim();
await trigger.press('Enter');
const dialog = page.getByRole('dialog');
await expect.soft(dialog, name + ': dialog should be open').toBeVisible();
await expect.soft(dialog.getByRole('heading'), name + ': focus should move to the dialog heading').toBeFocused();
await page.keyboard.press('Escape');
await expect.soft(dialog, name + ': dialog should close with Escape').toBeHidden();
// Find out where focus actually is, so a failure can say where it went
const focusedOn = await page.evaluate(() => document.activeElement.tagName.toLowerCase());
await expect.soft(
trigger,
name + ': focus should return to the button that opened the dialog (focus is currently on: <' + focusedOn + '>)'
).toBeFocused();
}
}
Don't copy this code as it stands. Three checks have been replaced with placeholders to keep the example short. These placeholders deliberately produce errors because an empty function could allow tests to pass without checking anything.
The complete file, containing all the reusable checks, is available in the playground repository.
The second file contains the tests that use these reusable checks:
// tests/site.spec.js
import { test } from '@playwright/test';
import { checkDisclosures, checkKeyboard, checkDialogs, checkForms } from './helpers/accessibility-checks.js';
// Every page on the site we want to check.
// To cover a new page, just add it to this list.
const sitePages = [
{ name: 'Home', url: 'https://intopia.github.io/playwright-testing/playground/site/index.html' },
{ name: 'Products', url: 'https://intopia.github.io/playwright-testing/playground/site/products.html' },
{ name: 'Help', url: 'https://intopia.github.io/playwright-testing/playground/site/help.html' },
];
// For each page, create a group of tests: one for each reusable check
for (const sitePage of sitePages) {
test.describe(sitePage.name + ' page', () => {
// Before each test in this group, open the page
test.beforeEach(async ({ page }) => {
await page.goto(sitePage.url);
});
test('disclosures keep aria-expanded in sync', async ({ page }) => {
await checkDisclosures(page, sitePage.name);
});
test('every control can be reached with the keyboard', async ({ page, browserName }) => {
// WebKit skips links with Tab by default (see example 3)
test.skip(browserName === 'webkit', 'WebKit skips links with Tab by default');
await checkKeyboard(page, sitePage.name);
});
test('dialogs manage focus', async ({ page }) => {
await checkDialogs(page, sitePage.name);
});
test('form errors are marked invalid and linked', async ({ page }) => {
await checkForms(page, sitePage.name);
});
});
}
This example introduces five concepts:
- Reusable functions. Each accessibility check is written once and can be imported into multiple tests.
- A list of pages. The test runs the same checks against every page in the list.
- Grouped tests.
test.describegroups tests by page, whiletest.beforeEachopens the page before each test. - Separate test results. Each check runs as a separate test for each page. This makes it easier to identify failures and allows the WebKit exception to apply only to keyboard tests.
- Clearer failure messages. Reports identify the page being tested and provide details about what went wrong, including, for dialogs, where keyboard focus ended up.
To test another page, add it to the list. To introduce a new accessibility check, create a reusable function and add it to the tests.
Try it yourself
The playground site is a demonstration outdoor equipment shop with three pages. Each page includes a note identifying any deliberately broken components.
Run the test:
npx playwright test tests/site.spec.js
This produces 36 results: three pages, four checks per page, across three browsers. Most tests pass, but two accessibility issues are identified:
| Test | Chromium | Firefox | WebKit |
|---|---|---|---|
| Products: keyboard | Fail | Fail | Skipped |
| Help: forms | Fail | Fail | Fail |
| All other tests | Pass | Pass | Pass |
Example failure messages:
Error: Products: could not reach this control with the Tab key:
<div role="button" class="add-to-cart fake-button">Add Trail bottle to cart</div>
Error: Help > Contact support > Email: should have aria-invalid="true"
These messages identify the page, the component and the problem, making it easier for developers to investigate failures.
Some checks pass because there's nothing for them to check. For example, the Home page has no forms, so the forms check finds nothing to test and passes.
This is an important limitation. A test may pass because there are no relevant components, or because it isn't finding components that should be tested. Running each check against a known failure helps confirm that it works as intended.
Limitations of this test
This test checks disclosures, keyboard navigation, dialogs and forms across three pages and three browser engines, using the same set of reusable checks.
These checks rely on consistent component patterns. For example, they identify disclosures using aria-controls, dialogs using aria-haspopup="dialog", and form errors using a specific naming convention.
Components built differently may need additional or modified checks. This highlights another benefit of using consistent, reusable components across a website.
Practical notes
A few additional considerations when working with Playwright:
- Check that your tests can detect failures. A passing test doesn't necessarily mean it's working correctly. Test it against a known accessibility issue to confirm that it identifies the problem.
- Accessibility tree information. Playwright's HTML reports can include a snapshot of the accessibility tree, showing elements by their roles and accessible names. This can help identify problems such as buttons without accessible names.
- Investigating failures. When trace recording is enabled, Playwright's trace viewer lets you examine what happened during a test, including actions, page snapshots and errors. This is particularly useful when investigating failures from automated builds.
Further exploration
Testing individual components
If your team already uses Playwright with Storybook or another component library, you can apply the same techniques to individual components. For example, you could test keyboard interactions, focus management and changes in ARIA states for each component.
See Playwright's component testing documentation for more information.
Other accessibility checks
There are many more accessibility behaviours that Playwright can help test, including:
- Form validation: Error summaries, focus management and validation of radio button and checkbox groups.
- Focus after navigation: Checking where focus moves when a single-page application changes views.
- Accessibility tree snapshots: Comparing accessible roles, names and states against expected results after an interaction.
For more information, explore the Playwright documentation and its accessibility testing guide.
Conclusion
Playwright allows developers to test accessibility-related behaviour during development, including keyboard interactions, focus management and changes in component states.
By including these tests in their development process, teams can identify issues before release and help prevent previously fixed problems from returning.
Start with a single accessibility requirement, write a test for it, and gradually expand your coverage.