How to run an axe accessibility test
Load the page, inject axe-core, and read the violations array. Against the order review page below, axe 4.13.0 printed violations 0 passes 25 incomplete 1. That page has four accessibility defects. The count is a floor on the defects present, and it is never a verdict on the page.
Why check this
Run axe on every pull request that changes markup, and again on staging against the rendered DOM, because the HTML the server sends and the DOM a framework builds are not the same document. It catches the failures that have a mechanical answer: an image with no alt, a field with no label, text below the contrast ratio, a button whose only content is an icon. Step 2 catches all four and quotes the element for each.
What axe cannot do is decide the page is usable. Every rule it ships has to be decidable from the DOM alone, and much of WCAG is not. Step 3 runs the same tool against a page built to pass while broken, so the shape of that gap is visible instead of stated.
Prerequisites
- Node 22 and Chrome installed locally.
npm i puppeteer-core axe-coregives the driver and the engine, and nothing downloads a second browser. The axe-core API document coversaxe.runand its options, and the rule descriptions list every rule with its tags. - The lab, two pages on one port.
/is an order review screen with four defects axe has no rule for./broken/is the same screen with four defects it does. Save it asaxe-demo.mjs.
// axe-demo.mjs - two pages on one port. node axe-demo.mjs
// / an order review page with four defects axe cannot see
// /broken/ the same page with four defects axe reports
import { createServer } from 'node:http';
const review = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Order review</title><style>
body{font:16px system-ui;color:#111;background:#fff;margin:2rem}
.row{display:flex;gap:.5rem}
#cancel{order:2} #continue{order:3} /* visual order, not DOM order */
</style></head><body>
<header><h1>Order review</h1></header>
<main>
<p>Two items, delivery on Friday.</p>
<div class="row"><button id="back">Back</button><button id="continue">Continue</button><button id="cancel">Cancel</button></div>
<p><a href="/terms-2026.pdf">Read more</a></p>
<button id="confirm" aria-labelledby="total-price">Confirm</button>
</main>
<footer><p>Returns accepted for 30 days.</p></footer>
<script>
document.getElementById('confirm').addEventListener('click', () => {
const s = document.createElement('div'); // live region created and
s.setAttribute('aria-live', 'polite'); // filled in the same frame,
s.textContent = 'Order placed.'; // so there is no change to
document.querySelector('main').appendChild(s); // announce
});
</script></body></html>`;
const broken = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Order review</title></head><body>
<header><h1>Order review</h1></header>
<main>
<img src="/box.png">
<input type="text" name="coupon">
<button id="apply"><svg width="16" height="16" aria-hidden="true"></svg></button>
<p style="color:#aaa;background:#fff">Delivery on Friday.</p>
</main>
<footer><p>Returns accepted for 30 days.</p></footer>
</body></html>`;
createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(req.url === '/broken/' ? broken : review);
}).listen(8477, () => console.log('http://127.0.0.1:8477/'));
- The browser extension runs the same engine from a DevTools panel and is the faster way to look at one screen. The scripted run is here because the output has to go into a build.
- The figures below are one capture, on one machine, with Chrome 152.0.7977.76 and axe-core 4.13.0. Rule counts move between axe versions.
Steps
- Step 1.
Start the lab in its own terminal.
node axe-demo.mjshttp://127.0.0.1:8477/ - Step 2.
Run axe against the page with defects it has rules for. Save the runner as
axe-run.mjs.// axe-run.mjs - node axe-run.mjs <url> import { launch } from 'puppeteer-core'; import { createRequire } from 'node:module'; import { readFileSync } from 'node:fs'; const axeSource = readFileSync(createRequire(import.meta.url).resolve('axe-core/axe.min.js'), 'utf8'); const browser = await launch({ channel: 'chrome', headless: true }); try { const page = await browser.newPage(); await page.goto(process.argv[2], { waitUntil: 'networkidle2' }); await page.evaluate(axeSource); const r = await page.evaluate(() => axe.run()); console.log('chrome', await browser.version(), '| axe-core', await page.evaluate(() => axe.version)); console.log('violations', r.violations.length, 'passes', r.passes.length, 'incomplete', r.incomplete.length, 'inapplicable', r.inapplicable.length); for (const v of r.violations) { console.log(`VIOLATION ${v.id} (${v.impact}): ${v.help}`); for (const n of v.nodes) console.log(` ${n.html}`); } for (const v of r.incomplete) { console.log(`INCOMPLETE ${v.id}: ${v.help}`); for (const n of v.nodes) console.log(` ${n.html}`); } } finally { await browser.close(); }node axe-run.mjs http://127.0.0.1:8477/broken/chrome Chrome/152.0.7977.76 | axe-core 4.13.0 violations 4 passes 23 incomplete 0 inapplicable 63 VIOLATION button-name (critical): Buttons must have discernible text <button id="apply"><svg width="16" height="16" aria-hidden="true"></svg></button> VIOLATION color-contrast (serious): Elements must meet minimum color contrast ratio thresholds <p style="color:#aaa;background:#fff">Delivery on Friday.</p> VIOLATION image-alt (critical): Images must have alternative text <img src="/box.png"> VIOLATION label (critical): Form elements must have labels <input type="text" name="coupon">Four rules fired, each with the element that failed it. This is the part of the job axe does better than a person: exhaustively, in a second, on every build.
- Step 3.
Point the same runner at the order review page.
node axe-run.mjs http://127.0.0.1:8477/chrome Chrome/152.0.7977.76 | axe-core 4.13.0 violations 0 passes 25 incomplete 1 inapplicable 63 INCOMPLETE aria-valid-attr-value: ARIA attributes must conform to valid values <button id="confirm" aria-labelledby="total-price">Confirm</button>Zero violations. A pipeline that fails on
violations.length > 0publishes this page. - Step 4.
Measure the four defects the run above reported nothing about. Save as
gaps.mjs.// gaps.mjs - the four defects on http://127.0.0.1:8477/ that axe reported nothing about import { launch } from 'puppeteer-core'; const browser = await launch({ channel: 'chrome', headless: true }); try { const page = await browser.newPage(); await page.goto('http://127.0.0.1:8477/', { waitUntil: 'networkidle2' }); const visual = await page.evaluate(() => [...document.querySelectorAll('.row button')] .sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x) .map((b) => b.id).join(' ')); const tab = []; for (let i = 0; i < 3; i++) { await page.keyboard.press('Tab'); tab.push(await page.evaluate(() => document.activeElement.id)); } console.log('1 painted left to right:', visual); console.log(' tab order :', tab.join(' ')); console.log('2 element with id total-price:', await page.evaluate(() => String(document.getElementById('total-price')))); console.log(' accessible name of #confirm:', JSON.stringify((await page.accessibility.snapshot({ root: await page.$('#confirm') })).name)); const mutations = await page.evaluate(async () => { const seen = []; new MutationObserver((m) => seen.push(...m)) .observe(document.querySelector('main'), { childList: true, subtree: true, characterData: true }); document.getElementById('confirm').click(); await new Promise((r) => setTimeout(r, 200)); return seen.map((r) => `${r.type}, ${r.addedNodes.length} node added, already reading ` + `"${[...r.addedNodes].map((n) => n.textContent).join('')}"`); }); console.log('3 mutations on the live region:', mutations.length); mutations.forEach((m) => console.log(' ' + m)); console.log('4 links:', await page.evaluate(() => [...document.links].map((a) => `"${a.textContent.trim()}" to ${a.getAttribute('href')}`).join(', '))); } finally { await browser.close(); }node gaps.mjs1 painted left to right: back cancel continue tab order : back continue cancel 2 element with id total-price: null accessible name of #confirm: "Confirm" 3 mutations on the live region: 1 childList, 1 node added, already reading "Order placed." 4 links: "Read more" to /terms-2026.pdfFour defects, one per line. The middle button on screen is the third Tab stop, so a keyboard user aiming at Cancel activates Continue (How to check focus order).
aria-labelledbypoints at an id that is not in the document, and the name falls through to the button's own text, which is whybutton-namepassed (How to check aria labels). The live region arrives with its text already in it, so there is no change for it to announce (How to test aria live regions). And a link reading Read more is accurate and says nothing about its destination (How to check link text for accessibility). - Step 5.
Read what the 25 passing rules were. Save as
axe-tags.mjs.// axe-tags.mjs - how many of the rules axe runs by default are WCAG rules import { launch } from 'puppeteer-core'; import { createRequire } from 'node:module'; import { readFileSync } from 'node:fs'; const axeSource = readFileSync(createRequire(import.meta.url).resolve('axe-core/axe.min.js'), 'utf8'); const browser = await launch({ channel: 'chrome', headless: true }); try { const page = await browser.newPage(); await page.goto('http://127.0.0.1:8477/', { waitUntil: 'networkidle2' }); await page.evaluate(axeSource); const all = await page.evaluate(() => axe.run()); const wcag = await page.evaluate(() => axe.run({ runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] })); const line = (r) => `violations ${r.violations.length} passes ${r.passes.length} incomplete ${r.incomplete.length}`; console.log('default run ', line(all)); console.log('runOnly wcag tags ', line(wcag)); const bp = all.passes.filter((p) => p.tags.includes('best-practice')).map((p) => p.id); console.log(`${bp.length} of the ${all.passes.length} passing rules carry no WCAG tag:`); console.log(' ' + bp.join(' ')); } finally { await browser.close(); }node axe-tags.mjsdefault run violations 0 passes 25 incomplete 1 runOnly wcag tags violations 0 passes 14 incomplete 1 12 of the 25 passing rules carry no WCAG tag: empty-heading heading-order landmark-banner-is-top-level landmark-contentinfo-is-top-level landmark-main-is-top-level landmark-no-duplicate-banner landmark-no-duplicate-contentinfo landmark-no-duplicate-main landmark-one-main landmark-unique page-has-heading-one regionAlmost half the green in a default run is advice, not conformance. Report the tag set with the count or the number means nothing.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| violations with entries | A rule decided against an element and quoted it | Fix it. Each entry carries the node, the rule id and the impact. |
| violations 0 | No rule reached a failing verdict | Nothing more. It is not a statement about the page, as step 4 shows. |
| incomplete above zero | axe could not decide and is asking for a person | Read each node by hand. A gate on violations.length never sees these. |
| inapplicable 63 | 63 rules found no element to test | The rule set that ran depends on what the page contains, so counts are not comparable between pages. |
| passes 25 on a default run | 12 of those rules are axe advice with no WCAG tag | Pass runOnly with the tag set you report against. |
Common mistakes
What to check next
- How to check lighthouse accessibility score: the same engine behind a single number, and what that number is an average of.
- How to check accessibility tree: the roles and names axe reads its verdicts from, one node at a time.
- How to test keyboard navigation on a website: the pass that finds the controls a static audit cannot reach.
- How to check color contrast ratio: the one axe rule whose result changes with a background image or a gradient.
FAQ
What is axe used for?
axe-core is a rule engine that inspects a loaded DOM and reports elements that fail a rule. It runs in the browser, in a DevTools extension, and in test frameworks through wrappers. It reports failures. It does not judge a page.
How do I use the axe accessibility tool without writing code?
Install the axe DevTools extension, open DevTools on the page, pick the axe panel and press Scan. Same engine, same rules, one screen at a time. A script is the way to keep the result reproducible across builds.
Does a clean axe run mean the page is accessible?
No. The order review page in step 3 reports zero violations while a keyboard user cannot cancel, a status message is never announced, and a link says nothing about where it goes.
How much of WCAG does axe cover?
Only what a rule can decide from the DOM. Whether a tab order preserves meaning, whether link text makes sense away from its paragraph, and whether an update was announced all need a person. Each one has a procedure on this site.
Can axe run in CI?
Yes, and it should. Assert on violations, and print the incomplete list where a reviewer will see it, since that bucket holds the results the engine deliberately refused to decide.
Verified
Verified by Maks Vernyaxe-core 4.13.0Chrome 152.0.7977.76puppeteer-core 25.10.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny