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

// 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/'));

Steps

  1. Step 1.

    Start the lab in its own terminal.

    node axe-demo.mjs
    
    http://127.0.0.1:8477/
  2. 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.

  3. 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 > 0 publishes this page.

  4. 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.mjs
    
    1 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.pdf

    Four 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-labelledby points at an id that is not in the document, and the name falls through to the button's own text, which is why button-name passed (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).

  5. 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.mjs
    
    default 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 region

    Almost 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

Sign: The run is green, the CI gate passes, and the page has a control nobody can name.Cause: An aria-labelledby pointing at an id that does not exist landed in incomplete, not in violations, because the button still got a name from its own text. Most pipelines assert on the length of violations alone, so the one result that asked for a human is the one nobody reads.
Sign: axe reports nothing about a dialog, a menu or a toast that is visibly broken.Cause: axe.run audits the DOM at the moment it is called. On the demo page the confirm button appends a node on click, and the run in step 3 happened before any click, so that node was never in scope. Drive the interaction first, then run axe again on the changed page.
Sign: Two teams quote different axe numbers for the same page on the same day.Cause: The default run includes rules tagged best-practice alongside WCAG rules, 12 out of 25 on the page above. A run filtered with runOnly to WCAG tags reports a different total from the same engine on the same DOM. The number is meaningless without the tag set beside it.

What to check next

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.

intermediate12 minpublished updated Maks Verny