Interface accessibility breaks easily and unnoticed: an edit removes a field's label, an icon button loses its accessible name, contrast falls below the norm after a palette change. A functional test will not catch this - it clicks elements it found itself and does not ask whether a person with a screen reader will reach them. An E2E suite that already drives the browser across real pages is the natural place to catch accessibility regressions early and automatically.
The main tool is axe, an accessibility-checking engine integrated with Playwright by the @axe-core/playwright package. In a test you create an AxeBuilder with the current page and call analyze(); the result contains a list of violations. The check comes down to asserting there are no violations: expect(results.violations).toEqual([]). Axe runs the page through a set of rules and finds typical problems - missing labels, insufficient contrast, wrong roles.
The check can be narrowed and tuned to the context. include and exclude limit the analysis to a specific area of the page - useful when a third-party widget cannot be fixed and is consciously excluded from the check. withTags selects a set of rules - for example, WCAG 2.0 A and AA levels - to check exactly the standard you promise. So axe becomes not a noisy scanner of everything but a precise check of the declared accessibility level.
It helps to see the basic check in full once to build it into the suite. Below is a test that opens the page, runs axe with selected WCAG tags and asserts the absence of violations. You return to this form when adding an accessibility check to key pages - sign-in, order placement, forms - where an inaccessible interface directly cuts off part of the users.
Alongside stands another mechanism - a snapshot of the accessibility tree via toMatchAriaSnapshot. Unlike a screenshot, it fixes not pixels but the structure of roles and accessible names: a heading, a list, buttons with their names. This is a robust contract of how the page is seen by a screen reader and the keyboard - it does not break from a color or padding change but goes red if a role or accessible name is gone. The semantic analog of a visual test.
The aria snapshot has its own niche compared to an ordinary screenshot. A visual test catches shifting and color but is silent about semantics; an aria snapshot catches semantics but is silent about pixels. For accessibility exactly the second matters: a screen-reader user does not care whether a button shifted by two pixels, but it is critical whether it has a name and a role. So an aria snapshot expresses the accessibility contract more precisely than an image.
An important caveat - automated checking does not replace manual. Axe finds machine-detectable violations but does not judge meaning: whether a label is clear, whether the focus order is logical, whether the page sounds sensible read aloud. Full accessibility requires checking with a real screen reader and keyboard. The value of automation is different: it catches regressions early and cheaply, keeping the already achieved level from silently degrading between releases.
The typical accessibility failures in E2E are predictable. A complete absence of such checks - and regressions surface for real users rather than in CI. Axe without include/exclude on a page with an unfixable third-party widget - a forever-red test that people stop reading. And the belief that a green axe means accessibility - whereas the machine catches only part. Check key pages with axe and the needed tags, fix semantics with an aria snapshot and complement this with manual checking.
npm i -D @axe-core/playwright
import AxeBuilder from '@axe-core/playwright'
test('the login page is accessible', async ({ page }) => {
await page.goto('/login')
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze()
expect(results.violations).toEqual([])
})
// A semantic contract: roles and accessible names, not pixels
await expect(page.getByRole('navigation')).toMatchAriaSnapshot(`
- navigation:
- link "Home"
- link "Orders"
`)