Turn accessibility checks into tests. Role queries, axe-core in Vitest, keyboard tests, Oxlint, axe in real browsers, and the checks only a person can do.
Part 46, Part 47 and Part 48 built pages that work with a keyboard and a screen reader. We checked them by hand and with axe-core. Part 46 also used Oxlint. Part 49 set up Vitest and React Testing Library to test components.
This part puts the two together. A check you do by hand, you do once. A test runs every time you change the code. So a lost label or a lost focus shows up before your users find it.
We’ll go layer by layer. First comes a linter, then component tests, then axe-core in real browsers. Last come the checks that only a person can do. Each layer finds things the others miss. We ran the first three for this post.
Try this first
Here is a small notice with a close button. The button shows the sign ×.
import { useState } from 'react'
export default function App() {
const [open, setOpen] = useState(true)
if (!open) return <p>The notice is closed.</p>
return (
<div>
<p>Your order has shipped.</p>
<button onClick={() => setOpen(false)}>×</button>
</div>
)
}
Run it and click ×. The notice closes. It works.
Save that code as App.tsx. Now here is a test for it, in a file next to it. It finds the button by its role and its name, the way Part 49 taught. Make a guess before you read on. Does this test pass?
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import App from './App'
test('the close button hides the notice', async () => {
const user = userEvent.setup()
render(<App />)
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.getByText('The notice is closed.')).toBeInTheDocument()
})
It fails. Here is the start of what Vitest printed:
TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "Close"
Here are the accessible roles:
paragraph:
Name "":
<p />
--------------------------------------------------
button:
Name "×":
<button />
The button works with a mouse. But its name is “×”. A sign is not a word, and it doesn’t say what the button does. Testing Library found no button named “Close”, so the test failed.
The fix is a name. Part 46 showed how:
function CloseButton({ onClose }: { onClose: () => void }) {
return (
<button aria-label="Close" onClick={onClose}>
×
</button>
)
}
With aria-label="Close" on the button, the same test passed.
A role query is an accessibility test
getByRole('button', { name: 'Close' }) asks two things. Is there an element with the role button? Is its accessible name “Close”? The accessible name is the name a screen reader reads out (Part 46 explained it).
Testing Library’s docs say getByRole can find “every element that is exposed in the accessibility tree”. That is the same tree a screen reader reads. The docs put getByRole first in their list, and add: “if you can’t, it’s possible your UI is inaccessible”. UI means user interface: the part of the app that people see and use. Inaccessible means some people can’t use it. That’s why getByRole comes first in Part 49’s order of queries.
We tried role queries on the mistakes from Parts 46 and 48. Each query failed, and each message said why:
| The code | The query | What Testing Library said |
|---|---|---|
<button>×</button> |
getByRole('button', { name: 'Close' }) |
found a button, Name "×" |
<input placeholder="Email" /> |
getByRole('textbox', { name: 'Email' }) |
found a textbox, Name "" |
<div onClick={save}>Save</div> |
getByRole('button', { name: 'Save' }) |
There are no accessible roles. |
<button aria-labeledby="t">Go</button> |
getByRole('button', { name: 'Title' }) |
found a button, Name "Go" |
getByLabelText('Email') failed on the placeholder box too: Unable to find a label with the text of: Email.
Notice the placeholder row. In Part 48, axe-core counted a placeholder as a name. Testing Library didn’t. Here the role query is stricter, and that helps: Part 48 explained why a placeholder is not a label.
A weaker query would hide all of this. We changed the “Try this first” test to screen.getByText('×'). It found the button, and the test passed. The button still had no name.
An everyday example
Think of a friend who can’t see, asking you: “Where is the Close button?” You can only point to a button if it has that name. If it has no name, you can’t help, even though the button is right there. A role query asks the same question.
The exact version
Testing Library is not a screen reader. It works out names with a package called dom-accessibility-api, and roles with a package called aria-query and its own code. Browsers have their own code for this. So does Playwright, a tool that opens real browsers and drives them from code. We’ll use it in layer 3. These don’t always agree. In Part 46, Chromium named the aria-labeledby button from that wrongly spelled attribute. Playwright named it “Go”, and so did Testing Library here.
So a passing role query means the name is right to Testing Library. It doesn’t prove what every screen reader will say.
Four layers of checks
No single tool finds everything. A package’s README is its main page of notes. The README of axe-core says it can find “on average 57% of WCAG issues automatically”. WCAG is the W3C’s list of rules for accessible web pages. The 57% is the tool maker’s own number. Deque’s report says how it was counted. It is the share of all problems found on more than 13,000 real pages, nearly 300,000 problems in all. Counted by rule instead, its tests found problems for 16 of the 50 WCAG 2.1 AA rules. The README also says axe-core marks some things as incomplete, where “manual review is needed”. That means a person must look.
So we check in layers. Here is the bad sign-up form from Part 48, going through them.
Each layer catches what the ones above it missed. Press play, or step through it.
The same steps in words:
- The bad sign-up form has four problems. A
<div>acts as a button. The box has no label. The error isn’t linked to its box. The error text is a light red. The fifth thing to check is how the page sounds. - Oxlint reads the code. It flags the
<div>withonClick. - Component tests with role queries flag the missing label and the error that isn’t linked. In jsdom, axe-core finds nothing.
- In a real browser, axe-core flags the red text. In jsdom, it couldn’t check it.
- How the page sounds, and if it makes sense, is left for a person.
Put each check in the first layer that can find it. The top layers run on every change, with no one watching. The last layer takes a person’s time, so save it for what only a person can find.
Layer 1: a linter reads your code
This is the form from Part 48. It has three problems in its code. A placeholder takes the place of a label. An error isn’t linked to its box. And a <div> acts as a button.
import { useState } from 'react'
export function SignupForm() {
const [email, setEmail] = useState('')
const [sent, setSent] = useState(false)
const wrong = sent && !email.includes('@')
return (
<form
onSubmit={(e) => {
e.preventDefault()
setSent(true)
}}
>
<input type="text" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
{wrong && <p style={{ color: 'red' }}>That doesn't look like an email</p>}
<div onClick={() => setSent(true)} style={{ background: '#1976d2', color: 'white', padding: 8 }}>
Sign up
</div>
</form>
)
}
We saved it as SignupForm.tsx in the project from Part 10, and ran Oxlint on it. With that project’s settings, Oxlint found nothing. Its jsx-a11y rules are off unless you turn them on. The name a11y is short for accessibility: an “a”, then 11 letters, then a “y”. With --jsx-a11y-plugin, Oxlint 1.87.0 found 2 warnings. Here are their first lines:
! jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
! jsx-a11y(no-static-element-interactions): Static HTML elements with event handlers require a role.
Both point at the <div> with onClick. Oxlint said nothing about the placeholder, or about the error that isn’t linked to its box. On the fixed form below, it found 0 warnings.
To turn the rules on for every run, add jsx-a11y to the plugins in .oxlintrc.json. Oxlint’s docs warn that this list replaces the default list, so keep the ones you had:
{
"plugins": ["react", "typescript", "oxc", "jsx-a11y"]
}
With this file, a plain oxlint run printed the same 2 warnings. If your project uses ESLint, these rules come in a package called eslint-plugin-jsx-a11y.
A linter only reads code. It can’t see a name that comes from a variable, or a color, or where the focus goes. That’s the next layers’ job.
Layer 2: component tests
The tests below run in Vitest, set up as in Part 49. They run in jsdom, a pretend browser for Node.js. It builds the DOM, but it draws nothing on a screen.
This is Part 49’s vite.config.ts, with one new line, exclude, and the import it needs:
/// <reference types="vitest/config" />
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { configDefaults } from 'vitest/config'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/setupTests.ts'],
exclude: [...configDefaults.exclude, 'e2e/**'],
},
})
The exclude line keeps Vitest out of the e2e folder, where layer 3’s browser tests will live. src/setupTests.ts is the same one line as in Part 49:
import '@testing-library/jest-dom/vitest'
It adds matchers like toHaveFocus() and toHaveAccessibleDescription().
Roles, names and descriptions
Here are four tests for the sign-up form. Three ask about roles, names and descriptions. One runs axe-core, which we’ll look at next. Add axe-core to your project first:
npm install -D axe-core
import { useState } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import axe from 'axe-core'
import { expect, test } from 'vitest'
async function axeProblems(container: Element) {
const results = await axe.run(container, {
rules: { 'color-contrast': { enabled: false } },
})
return [
...results.violations.map((v) => `${v.id}: ${v.help}`),
...results.incomplete.map((v) => `not checked, ${v.id}: ${v.help}`),
]
}
function SignupForm() {
const [email, setEmail] = useState('')
const [sent, setSent] = useState(false)
const wrong = sent && !email.includes('@')
return (
<form
onSubmit={(e) => {
e.preventDefault()
setSent(true)
}}
>
<input type="text" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
{wrong && <p style={{ color: 'red' }}>That doesn't look like an email</p>}
<div onClick={() => setSent(true)} style={{ background: '#1976d2', color: 'white', padding: 8 }}>
Sign up
</div>
</form>
)
}
test('the email box has a name', () => {
render(<SignupForm />)
expect(screen.getByRole('textbox', { name: 'Email' })).toBeInTheDocument()
})
test('Sign up is a button', () => {
render(<SignupForm />)
expect(screen.getByRole('button', { name: 'Sign up' })).toBeInTheDocument()
})
test('the error is linked to the box', async () => {
const user = userEvent.setup()
render(<SignupForm />)
await user.type(screen.getByRole('textbox'), 'ana')
await user.click(screen.getByText('Sign up'))
expect(screen.getByRole('textbox')).toHaveAccessibleDescription("That doesn't look like an email")
})
test('axe finds no problems', async () => {
const user = userEvent.setup()
const { container } = render(<SignupForm />)
await user.click(screen.getByText('Sign up'))
expect(await axeProblems(container)).toEqual([])
})
For this post, the form sits in the test file, so you can read it all in one place. In a real project, import it from its own file.
Three of the four tests failed. The fourth, the axe-core test, passed. Here is what each failure said:
- “the email box has a name”:
Unable to find an accessible element with the role "textbox" and name "Email". It listed onetextbox, withName "". - “Sign up is a button”:
Unable to find an accessible element with the role "button" and name "Sign up". The only role it listed was thetextbox. To Testing Library, the<div>is not a button at all. - “the error is linked to the box”: see below.
The third test needs a word. An element’s accessible description is extra text that a screen reader can read, like a hint or an error. Part 48 linked them with aria-describedby. The error paragraph is on the page, but nothing links it to the box. So Testing Library found no description:
Error: expect(element).toHaveAccessibleDescription()
Expected element to have accessible description:
That doesn't look like an email
Received:
Here is the form fixed with the tools from Part 48. It has a label, a real button, and an error linked to the box:
import { useId, useState } from 'react'
export function SignupForm() {
const [email, setEmail] = useState('')
const [sent, setSent] = useState(false)
const errorId = useId()
const wrong = sent && !email.includes('@')
return (
<form
noValidate
onSubmit={(e) => {
e.preventDefault()
setSent(true)
}}
>
<label>
Email{' '}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={wrong}
aria-describedby={wrong ? errorId : undefined}
/>
</label>
{wrong && (
<p id={errorId} style={{ color: '#b00020' }}>
That doesn't look like an email
</p>
)}
<button>Sign up</button>
</form>
)
}
We put it in place of the bad form and ran the same four tests. All four passed.
axe-core inside a test
The fourth test calls our small helper, axeProblems. It runs axe-core on the part of the page that the test rendered. Then it turns each problem into one line of text, like "button-name: Buttons must have discernible text". It also adds a line for each check that axe-core couldn’t finish, starting with “not checked”. We’ll see why below. The test expects an empty list.
On the bad sign-up form, that test passed. It found no problems at all. That matches Part 48. There, axe-core missed a placeholder used as a name, and a <div> with a click handler. The role queries caught both.
But axe-core does find other things. Here is the Toolbar from Part 46, in a test:
import { render } from '@testing-library/react'
import axe from 'axe-core'
import { expect, test } from 'vitest'
async function axeProblems(container: Element) {
const results = await axe.run(container, {
rules: { 'color-contrast': { enabled: false } },
})
return [
...results.violations.map((v) => `${v.id}: ${v.help}`),
...results.incomplete.map((v) => `not checked, ${v.id}: ${v.help}`),
]
}
function Toolbar({ onSave }: { onSave: () => void }) {
return (
<div>
<div onClick={onSave}>Save</div>
<div role="button" onClick={onSave}>Save</div>
<button aria-labeledby="title">Go</button>
<div aria-hidden="true">
<a href="/help">Help</a>
</div>
</div>
)
}
test('the toolbar has no axe problems', async () => {
const { container } = render(<Toolbar onSave={() => {}} />)
expect(await axeProblems(container)).toEqual([])
})
It failed, and Vitest showed what was different from the empty list:
AssertionError: expected [ …(2) ] to deeply equal []
- Expected
+ Received
- []
+ [
+ "aria-valid-attr: ARIA attributes must conform to valid names",
+ "not checked, aria-hidden-focus: ARIA hidden element must not be focusable or contain focusable elements",
+ ]
The first line is aria-labeledby, with one “l” missing. React also printed its own warning about it, as in Part 46. The second line is the link inside aria-hidden. In Chromium, Part 46’s run found it as a plain problem. In jsdom, axe-core couldn’t check it. We’ll see why below.
There’s also a package for this, vitest-axe. It adds a matcher, expect(results).toHaveNoViolations(), and prints a longer message for each problem. Its usual version, 0.1.0, is from 2022. With Vitest 5, it worked when the test ran, but TypeScript didn’t know the new matcher: Property 'toHaveNoViolations' does not exist on type 'Assertion<void, AxeResults>'. A test version, 1.0.0-pre.5 from January 2025, worked and passed TypeScript too. We kept our helper, because it needs no extra package.
What axe-core can’t check in jsdom
Look at the helper again. It turns off one rule, color-contrast. That rule checks that text stands out clearly from its background. But jsdom draws nothing. It has no colors on a screen to compare.
The README of axe-core says the color-contrast rule “is known not to work with JSDOM”. We left the rule on. Then we ran axe-core on the bad form, with its red error showing. The rule didn’t fail the test. It went into the incomplete list, with this message:
Axe encountered an error; test the page for this type of problem manually
The error inside it was Cannot read properties of null (reading 'canvas') Skipping color-contrast rule. jsdom also printed a line of its own: Not implemented: HTMLCanvasElement's getContext() method: without installing the canvas npm package. A canvas is a part of a page that code can draw on. The rule asked jsdom for one, and jsdom has none. That line names a package, but the README still says the rule doesn’t work with jsdom.
So with the rule on, a test that checks only violations still passes, and says nothing about color. That’s why our helper reads incomplete too. Turn the rule off in jsdom, as the README advises, and check color in a real browser. That’s layer 3.
The Toolbar‘s “not checked” line has the same cause. axe-core gave the same message. The error inside was this: document.elementFromPoint is not a function Skipping aria-hidden-focus rule.
That function finds the element at a point on the screen. jsdom has no layout, so it doesn’t have it. In the browsers below, the same link was a plain problem. The API docs of axe-core say why a check ends up in incomplete. Either the rule can’t test that thing, or “a JavaScript error occurred”. A person, or a real browser, has to look at those.
Keyboard tests
A role query checks names. It doesn’t press any keys. For that, use user-event, which Part 49 introduced. Two calls do most of the work:
await user.tab()presses Tab.await user.tab({ shift: true })presses Shift+Tab.await user.keyboard('{ArrowRight}')presses one key. The name inside{ }is the key’s name, like{Enter},{Escape}or{Home}.
And one matcher checks where the focus is: expect(element).toHaveFocus().
The tabs from Part 39
We saved the tabs from Part 39‘s “Try this first” as Tabs.tsx. These tests check the keys that Part 39 built:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import App from './Tabs'
test('the arrow keys move between tabs and wrap', async () => {
const user = userEvent.setup()
render(<App />)
await user.click(screen.getByRole('tab', { name: 'Profile' }))
await user.keyboard('{ArrowRight}')
const settings = screen.getByRole('tab', { name: 'Settings' })
expect(settings).toHaveFocus()
expect(settings).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tabpanel')).toHaveTextContent('Email me: yes')
await user.keyboard('{End}')
expect(screen.getByRole('tab', { name: 'Billing' })).toHaveFocus()
await user.keyboard('{ArrowRight}')
expect(screen.getByRole('tab', { name: 'Profile' })).toHaveFocus()
})
test('Tab goes from the selected tab to its panel', async () => {
const user = userEvent.setup()
render(<App />)
await user.tab()
expect(screen.getByRole('tab', { name: 'Profile' })).toHaveFocus()
await user.tab()
expect(screen.getByRole('tabpanel', { name: 'Profile' })).toHaveFocus()
})
getByRole('tabpanel') finds only the panel that shows. The hidden panels have the hidden attribute, and Testing Library leaves hidden elements out. The panel’s name, “Profile”, comes from its aria-labelledby, which points at its tab.
Both tests passed.
A test is only useful if it fails when the code breaks. So we broke the code. In Part 39, axe-core found nothing wrong when every tab was a Tab stop. We took out the roving tabIndex line again, and ran the tests:
TestingLibraryElementError: Unable to find an accessible element with the role "tabpanel" and name "Profile"
The second test caught it. The second Tab went to the “Settings” tab, not to the panel. The tab’s onFocus selected it, so the Profile panel was hidden. Part 39’s axe-core run couldn’t see this. A keyboard test could.
The focus trap from Part 47
We saved the modal from Part 47 as SettingsDialog.tsx. Part 47 pressed these keys in three browsers. Here they are as tests:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import App from './SettingsDialog'
test('Tab and Shift+Tab stay inside the modal', async () => {
const user = userEvent.setup()
render(<App />)
await user.click(screen.getByRole('button', { name: 'Open settings' }))
const close = screen.getByRole('button', { name: 'Close' })
const name = screen.getByRole('textbox', { name: 'Name' })
const save = screen.getByRole('button', { name: 'Save' })
expect(close).toHaveFocus()
await user.tab()
expect(name).toHaveFocus()
await user.tab()
expect(save).toHaveFocus()
await user.tab()
expect(close).toHaveFocus()
await user.tab({ shift: true })
expect(save).toHaveFocus()
await user.tab({ shift: true })
expect(name).toHaveFocus()
await user.tab({ shift: true })
expect(close).toHaveFocus()
})
test('Escape closes the modal and gives the focus back', async () => {
const user = userEvent.setup()
render(<App />)
const open = screen.getByRole('button', { name: 'Open settings' })
await user.click(open)
expect(screen.getByRole('dialog', { name: 'Settings' })).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(open).toHaveFocus()
})
queryByRole returns null when it finds nothing, instead of failing. That is how you check that something is gone. The dialog’s name, “Settings”, comes from its aria-labelledby.
Both tests passed.
Then we broke the code again. We deleted the line useFocusTrap(boxRef):
Error: expect(element).toHaveFocus()
Expected element with focus:
<input
aria-label="Name"
placeholder="Name"
/>
Received element with focus:
<body
style="overflow: hidden;"
>
The first Tab from Close left the modal, and the focus went to <body>. The Escape test still passed, because it never presses Tab. Each test checks only what it does.
jsdom doesn’t know inert
Part 47 used both the trap and inert. jsdom doesn’t support inert. With the modal open, <main> had the inert attribute. But focus() on “Open settings” still moved the focus there from Close. In Chromium, Firefox and WebKit, the same call left the focus on Close.
So a jsdom test can’t check what inert does. That needs a real browser, which is layer 3.
Testing a live region
Part 46 built a cart message in a live region: <p role="status">. A screen reader reads the new text when it changes. A test can’t listen to a screen reader. But it can check two things. The region is on the page from the start. And its text changes when it should.
import { useState } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
function Cart() {
const [items, setItems] = useState(0)
return (
<div>
<button onClick={() => setItems(items + 1)}>Add to cart</button>
<p role="status">{items === 0 ? '' : `Items in your cart: ${items}`}</p>
</div>
)
}
test('the status is there first, then its text changes', async () => {
const user = userEvent.setup()
render(<Cart />)
const status = screen.getByRole('status')
expect(status).toBeEmptyDOMElement()
await user.click(screen.getByRole('button', { name: 'Add to cart' }))
expect(status).toHaveTextContent('Items in your cart: 1')
})
It passed.
Part 46 warned against a region that appears together with its message: {items > 0 && <p role="status">…</p>}. We changed Cart to that. The test failed on its first getByRole('status'):
TestingLibraryElementError: Unable to find an accessible element with the role "status"
Here are the accessible roles:
button:
Name "Add to cart":
<button />
So the test checks Part 46’s rule: render the region first, change only its text. For an error that the user must hear right away, use role="alert" and getByRole('alert') the same way.
Layer 3: axe-core in a real browser
In jsdom, we couldn’t check colors, layout or inert. A real browser can. Playwright, which we met above, can drive Chromium, Firefox and WebKit. WebKit is the base of Safari. Deque, the company that makes axe-core, has a package for it: @axe-core/playwright.
Install both packages. Then let Playwright download its browsers:
npm install -D @playwright/test @axe-core/playwright
npx playwright install
Playwright tests open a page by its address, like a user does. So the app needs a page with the form. Make src/App.tsx show it, inside <main> with an <h1>, as a real page would:
import { SignupForm } from './SignupForm'
function App() {
return (
<main>
<h1>Join us</h1>
<SignupForm />
</main>
)
}
export default App
SignupForm.tsx is the bad form from layer 1. The app must also be running. This settings file builds the app and starts vite preview. Then it runs each test in all three browsers:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
use: { baseURL: 'http://localhost:4173' },
webServer: {
command: 'npx vite build && npx vite preview',
url: 'http://localhost:4173',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
})
Playwright’s docs show this shape of test. We saved it as e2e/signup.spec.ts:
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
test('the sign-up form has no axe problems', async ({ page }) => {
await page.goto('/')
await page.getByText('Sign up').click()
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations.map((v) => v.id)).toEqual([])
})
The test clicks “Sign up” first, so the red error is on the page when axe-core looks.
We ran it on the bad form, in all three browsers:
Error: expect(received).toEqual(expected) // deep equality
- Expected - 1
+ Received + 3
- Array []
+ Array [
+ "color-contrast",
+ ]
All three browsers gave the same answer: one problem, color-contrast. This is the message axe-core gave for the red text:
Element has insufficient color contrast of 3.99 (foreground color: #ff0000, background color: #ffffff, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1
The contrast ratio says how much the text stands out from its background. Plain red on white has 3.99. For text this size, the rule wants at least 4.5. Part 48 saw the same problem.
Why the <main> and the <h1>? AxeBuilder checks the whole page, unless you narrow it with .include(). Some rules check the page as a whole. We tried the form alone on the page, with no <main> and no <h1>. Then axe-core found three more problems: landmark-one-main, page-has-heading-one and region. A landmark is a main part of a page, like <main> or <nav> (Part 46 met them). In jsdom, our helper passed one element to axe-core, not the page. axe-core’s code skips the whole-page rules then. When we passed the whole document in jsdom instead, region showed up there too.
Then we put the fixed form in App.tsx. In all three browsers, axe-core returned an empty list. The test passed. The darker red, #b00020, stands out enough.
We also ran axe-core on Part 46’s Toolbar in the browsers. All three found both problems that Part 46 found in Chromium: aria-hidden-focus and aria-valid-attr. In jsdom, the first one was only “not checked”.
A real browser can check inert too. We opened Part 47’s modal in all three browsers, and called focus() on “Open settings” behind it. The focus stayed on Close. In Playwright, expect(locator).toBeFocused() checks where the focus is, like toHaveFocus() does in Vitest.
One more detail. The version number of @axe-core/playwright follows the axe-core it brings. Ours, 4.13.0, brought axe-core 4.13.0. Our jsdom tests used 4.14.0. So check which axe-core each tool uses.
Browser tests need a built app and real browsers, so they take longer to run. So keep most checks in component tests. Use the browser for what jsdom can’t do: colors, layout and real focus.
Layer 4: what only a person can check
The W3C’s page “Easy Checks” lists quick checks that anyone can do. It also warns that a page “could seem to pass these checks, yet still have significant accessibility barriers”. The W3C’s page on tools says the same. Tools “can only assist” in finding out if a page is accessible.
Here is a short list to do before you ship a new page, or a big change. The first two come from Easy Checks.
- Keyboard only. Put the mouse aside. Press Tab and Shift+Tab through the whole page. Easy Checks asks: can you reach everything, and leave everything? Does the order follow the order you read in? Can you always see where the focus is? Can you do everything without the mouse?
- Make the text 200% bigger. WCAG asks that text can grow to 200 percent, and the page still works. Easy Checks uses text-only zoom: a browser setting that makes only the text bigger. To zoom means to make something bigger on the screen. Easy Checks says that page zoom, which makes the whole page bigger, “does not usually reveal” these problems. Then look. No text is cut off. Nothing sits on top of anything else. Every button and box can still be used. You don’t have to scroll left and right to read a sentence. (Easy Checks also has a newer draft page, which uses page zoom.)
- A screen reader, for one task. Turn on VoiceOver or Narrator, as Part 46 suggested. Do one real task, like signing up. Listen. Does each control say what it is? Do you hear the error? Do you hear “Items in your cart: 1”?
Our tests checked that the names are right and the live region changes. Only this step tells you what a person hears, and if the page makes sense.
Common mistakes
Finding elements by text or test id only
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
const user = userEvent.setup()
await user.click(screen.getByText('×'))
This found the × button, and the test passed. But the button had no name. Find buttons, links, boxes and tabs with getByRole and a name. Then a missing name fails the test.
Treating a passing axe test as “accessible”
On the bad sign-up form, axeProblems returned an empty list. The form still had no label and no real button. axe-core is one layer. Add role queries, keyboard tests and the manual checks.
Reading only violations
A rule that can’t run in jsdom doesn’t fail. It goes into incomplete. A test that reads only violations still passes, and says nothing. That happened to color-contrast and aria-hidden-focus. Read incomplete too, as our helper does. Turn off the rules that can never run in jsdom, and check those in a real browser.
Pressing Tab with fireEvent
import { fireEvent, screen } from '@testing-library/react'
fireEvent.keyDown(screen.getByRole('tab', { name: 'Profile' }), { key: 'Tab' })
fireEvent.keyDown sends only the event. We sent Tab this way to the “Profile” tab. The focus stayed on “Profile”. The browser’s own work, like moving the focus on Tab, doesn’t happen. user.tab() does that work, as a browser would. Use user-event for keys.
Checking a live region with findByText
await screen.findByText('Items in your cart: 1') passed for both versions of Cart, the good one and the one where the region appears with its message. It only checks that the words show up somewhere, at some time. Get the region by its role before the change, as our test did.
Trusting jsdom for inert or layout
jsdom ignores inert, and it has no layout. A focus trap test there checks the trap’s code, not inert. Check those in a real browser.
Practice
Do these in the project from Part 49, with the files from this part.
- Add a test to the tabs: focus “Billing”, press Home, and check that “Profile” has the focus.
- In
Cart, changerole="status"toaria-live="polite". Is it still a live region? Does our test still pass? Why? - In the bad sign-up form, write a keyboard test. Press Tab twice from the top of the page. Where do you expect the focus to be? What does the test say?
- In Part 47’s modal, delete
tabIndex={-1}from the dialog box. Which of our two modal tests fails?
Answers
- Click “Billing” with
await user.click(screen.getByRole('tab', { name: 'Billing' })). A click gives it the focus. Then pressawait user.keyboard('{Home}'), and checkexpect(screen.getByRole('tab', { name: 'Profile' })).toHaveFocus(). We ran it, and it passed. - It is still a live region: Part 46 showed that
aria-live="polite"makes one. But the test failed atgetByRole('status'). Testing Library listed the<p>as aparagraphwithName "".aria-livedoesn’t give a role.role="status"gives both the role and the polite live region, so keep it. - You might expect the focus on “Sign up”. It isn’t there. The first Tab went to the email box. The second went to
<body>, because Tab can’t reach a<div>. The test failed withexpect(element).toHaveFocus(), and the focus it received was<body>. - Neither. Both tests still passed.
tabIndex={-1}is for a click on the text inside the white box (Part 47). Our tests never click there. A test checks only what it does.
Interview questions
Try to answer each one out loud before you open the answer.
Can automatic tests prove that a page is accessible?
No. They can prove that some things are right: names, roles, focus moves and some ARIA rules. The README of axe-core says it finds “on average 57% of WCAG issues automatically”. That’s the tool maker’s own number, counted over all the problems on real pages. Counted by WCAG rule, its tests found problems for only 16 of 50. In our tests, axe-core missed a placeholder used as a name and a <div> used as a button. A person still has to try the page with a keyboard and a screen reader, and zoom it.
A strong answer names the four layers. They are a linter, component tests, axe-core in real browsers, and checks by a person. Each one finds things the others miss.
Why is a role query with a name called an accessibility test?
It finds elements the way a screen reader does: by role and accessible name, from the accessibility tree. If a button has no name, or a <div> pretends to be a button, the query fails. In our test, a query by the button’s text passed even with no name.
A strong answer adds the limits. Testing Library works out names with its own code. It can differ from a browser. In our tests, it gave a placeholder-only box no name, while axe-core counted the placeholder.
What can’t axe-core check in jsdom?
Color contrast. In jsdom nothing is drawn, so there are no colors to compare. The README says the color-contrast rule doesn’t work there. In our run, it went into incomplete, not violations. So a test that reads only violations still passed. And jsdom has no layout, and it doesn’t support inert. On Part 46’s Toolbar, the link inside aria-hidden was a violation in real browsers. In jsdom, it was only incomplete, because document.elementFromPoint is missing. Turn the rule off in jsdom, and run axe-core in a real browser with Playwright.
How would you test a focus trap in a modal?
Open the modal with user.click. Check that the first focus is where you expect, with toHaveFocus(). Press user.tab() past the last element and check the focus wraps to the first. Press user.tab({ shift: true }) on the first and check it wraps to the last. Press Escape with user.keyboard('{Escape}'), check the dialog is gone with queryByRole, and check the focus went back to the opener.
A strong answer says to break the trap on purpose and see the test fail. It also says jsdom ignores inert, so a real-browser test checks that the page behind can’t get the focus.
How do you test a live region?
Get it by role, getByRole('status') or getByRole('alert'), before the change. Check it’s there and empty. Do the action. Check its text. That proves the region was on the page first, which screen readers need. It doesn’t prove a screen reader read it out. Only a manual check with a screen reader shows that.
What does a linter like Oxlint with jsx-a11y catch that axe-core misses, and the other way around?
A linter reads your code. It flags a <div> with onClick and no keyboard handler. In Parts 46 and 48, axe-core missed that. But axe-core looks at the finished page. In Part 46, it found a link inside aria-hidden, which Oxlint didn’t flag. The linter can’t see values that come from variables, or colors. Use both.
What are axe-core’s incomplete results?
Checks that axe-core couldn’t decide. Its API docs call them “needs review”. Either the rule couldn’t test the element, or “a JavaScript error occurred”. A person should look at them. A test that checks only violations ignores them, so a rule that can’t run fails silently.
Sources
- axe-core README, Deque: “on average 57% of WCAG issues”, “incomplete” results, and that
color-contrastdoesn’t work with JSDOM. - The Automated Accessibility Coverage Report, Deque: how the 57% was counted (13,000+ pages, nearly 300,000 problems), and 16 of 50 WCAG 2.1 AA rules.
- axe-core API, Deque:
axe.run, its options, and theviolationsandincompletelists. - About Queries, Testing Library: the order of queries, and “if you can’t, it’s possible your UI is inaccessible”.
- ByRole, Testing Library: the
nameoption and hidden elements. - Convenience APIs and keyboard(), user-event:
tab()and key names. - jest-dom README, Testing Library:
toHaveFocus,toHaveAccessibleDescription,toBeEmptyDOMElementandtoHaveTextContent. - Built-in Plugins, Oxlint: turning on
jsx-a11ywith a flag or in.oxlintrc.json, and that the list replaces the defaults. - Accessibility testing, Playwright:
AxeBuilder, and that many problems “can only be discovered through manual testing”. - Easy Checks – A First Review of Web Accessibility, W3C: the keyboard and text-only zoom checks, the note on page zoom, and the warning about pages that pass them.
- Selecting Web Accessibility Evaluation Tools, W3C: tools “can only assist”.
- Easy Checks: Zoom (draft), W3C: the newer draft check, with page zoom.
- Understanding Resize Text, W3C: text that grows to 200 percent.
- The test results, error messages and lint output come from running Vitest 5.0.3, React Testing Library 16.3.3, user-event 14.6.7, jest-dom 7.0.1, jsdom 30.1.2, axe-core 4.14.0, vitest-axe 0.1.0, TypeScript 7.0.2 and Oxlint 1.87.0 with React 19.3.0 and Vite 8.3.3, for this post. The browser results come from Playwright 1.62.1 and @axe-core/playwright 4.13.0, with its own axe-core 4.13.0, in headless Chromium 151, Firefox 153 and WebKit 26.5.
- This part follows the Testing Accessibility kata in react-katas.