Testing

Accessibility Testing Automation

A practical guide to automating accessibility testing with axe-core, Playwright, pa11y, and ESLint plugins to catch WCAG violations in Node.js web applications.

Accessibility Testing Automation

Accessibility bugs affect real users. Screen readers cannot navigate your page, keyboard users cannot reach buttons, low-vision users cannot read your text. These are not edge cases — over one billion people worldwide have some form of disability. Automated accessibility testing catches the mechanical violations: missing alt text, insufficient color contrast, broken ARIA attributes, missing form labels.

Automation cannot catch everything — it finds roughly 30-50% of accessibility issues. But the issues it catches are the easy ones to fix, and catching them automatically means developers fix them before users encounter them.

Prerequisites

  • Node.js installed (v16+)
  • A web application with HTML pages
  • Basic understanding of HTML semantics

Understanding WCAG

The Web Content Accessibility Guidelines (WCAG) define success criteria at three levels:

  • Level A — Minimum accessibility. Missing these creates barriers for many users.
  • Level AA — Standard target for most organizations. Required by many regulations.
  • Level AAA — Highest level. Not always achievable for all content types.

Common violations automated tools catch:

Violation WCAG Criterion Impact
Missing alt text 1.1.1 Screen readers cannot describe images
Low color contrast 1.4.3 Text unreadable for low-vision users
Missing form labels 1.3.1 Screen readers cannot identify form fields
Missing page language 3.1.1 Screen readers use wrong pronunciation
Empty links 2.4.4 Screen readers announce "link" with no context
Missing heading structure 1.3.1 Navigation by headings is broken

axe-core: The Foundation

axe-core is the most widely used accessibility testing engine. Most other tools (including browser extensions, CI tools, and testing libraries) use axe-core under the hood.

Using axe-core Directly

npm install --save-dev axe-core
// axe-check.js
var axe = require("axe-core");
var jsdom = require("jsdom");
var JSDOM = jsdom.JSDOM;
var fs = require("fs");

function checkAccessibility(htmlContent) {
  var dom = new JSDOM(htmlContent, { runScripts: "outside-only" });
  var document = dom.window.document;

  // axe-core needs to run in a document context
  return new Promise(function(resolve) {
    axe.run(document.documentElement, {
      rules: {
        // Focus on WCAG 2.1 AA
        "color-contrast": { enabled: true },
        "label": { enabled: true },
        "image-alt": { enabled: true },
        "html-has-lang": { enabled: true }
      }
    }, function(err, results) {
      if (err) throw err;
      resolve(results);
    });
  });
}

// Check a local HTML file
var html = fs.readFileSync("index.html", "utf8");
checkAccessibility(html).then(function(results) {
  if (results.violations.length > 0) {
    console.log("Found " + results.violations.length + " violations:\n");
    results.violations.forEach(function(violation) {
      console.log("Rule: " + violation.id);
      console.log("Impact: " + violation.impact);
      console.log("Description: " + violation.description);
      console.log("Elements affected: " + violation.nodes.length);
      violation.nodes.forEach(function(node) {
        console.log("  " + node.html);
        console.log("  Fix: " + node.failureSummary);
      });
      console.log("");
    });
  } else {
    console.log("No accessibility violations found");
  }
});

Playwright + axe-core

The most powerful combination for automated accessibility testing. Playwright renders real pages; axe-core analyzes them.

Setup

npm install --save-dev @playwright/test @axe-core/playwright

Writing Accessibility Tests

// accessibility.spec.js
var { test, expect } = require("@playwright/test");
var AxeBuilder = require("@axe-core/playwright").default;

test.describe("Accessibility", function() {
  test("homepage has no violations", async function({ page }) {
    await page.goto("http://localhost:3000");

    var results = await new AxeBuilder({ page }).analyze();

    expect(results.violations).toEqual([]);
  });

  test("articles page has no violations", async function({ page }) {
    await page.goto("http://localhost:3000/articles");

    var results = await new AxeBuilder({ page }).analyze();

    expect(results.violations).toEqual([]);
  });

  test("contact form has no violations", async function({ page }) {
    await page.goto("http://localhost:3000/contact");

    // Test the form in its default state
    var defaultResults = await new AxeBuilder({ page }).analyze();
    expect(defaultResults.violations).toEqual([]);

    // Fill out the form to test dynamic states
    await page.fill("#name", "Test User");
    await page.fill("#email", "invalid-email");
    await page.click("button[type=submit]");

    // Test the form with validation errors showing
    var errorResults = await new AxeBuilder({ page }).analyze();
    expect(errorResults.violations).toEqual([]);
  });
});

Filtering by WCAG Level

test("meets WCAG 2.1 AA", async function({ page }) {
  await page.goto("http://localhost:3000");

  var results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
    .analyze();

  expect(results.violations).toEqual([]);
});

Excluding Known Issues

While fixing violations, exclude specific rules temporarily:

test("homepage accessibility (excluding known issues)", async function({ page }) {
  await page.goto("http://localhost:3000");

  var results = await new AxeBuilder({ page })
    .disableRules(["color-contrast"]) // Will fix in issue #456
    .exclude(".third-party-widget")    // Cannot control third-party markup
    .analyze();

  expect(results.violations).toEqual([]);
});

Testing Specific Components

test("navigation is accessible", async function({ page }) {
  await page.goto("http://localhost:3000");

  var results = await new AxeBuilder({ page })
    .include("nav")
    .analyze();

  expect(results.violations).toEqual([]);
});

test("footer is accessible", async function({ page }) {
  await page.goto("http://localhost:3000");

  var results = await new AxeBuilder({ page })
    .include("footer")
    .analyze();

  expect(results.violations).toEqual([]);
});

pa11y: Command-Line Accessibility Testing

pa11y is a standalone accessibility testing tool that runs from the command line.

Setup

npm install --save-dev pa11y

Basic Usage

npx pa11y http://localhost:3000

Output:

Results for http://localhost:3000

 • Error: <img src="logo.png"> - Images must have alternate text
   WCAG2AA.Principle1.Guideline1_1.1_1_1.H37
   #header > img

 • Error: <input type="email"> - This form field should be labelled
   WCAG2AA.Principle1.Guideline1_3.1_3_1.F68
   #newsletter-signup > input

 • Warning: <p style="color: #999"> - This element has insufficient contrast
   WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail
   .footer-text

3 Errors, 1 Warning

pa11y Configuration File

{
  "defaults": {
    "standard": "WCAG2AA",
    "timeout": 10000,
    "wait": 1000,
    "chromeLaunchConfig": {
      "args": ["--no-sandbox"]
    }
  },
  "urls": [
    "http://localhost:3000",
    "http://localhost:3000/articles",
    "http://localhost:3000/contact",
    {
      "url": "http://localhost:3000/login",
      "actions": [
        "set field #email to [email protected]",
        "set field #password to password123",
        "click element button[type=submit]",
        "wait for url to be http://localhost:3000/dashboard"
      ]
    }
  ]
}

pa11y-ci for Continuous Integration

npm install --save-dev pa11y-ci
{
  "defaults": {
    "standard": "WCAG2AA",
    "timeout": 30000
  },
  "urls": [
    "http://localhost:3000",
    "http://localhost:3000/articles",
    "http://localhost:3000/contact"
  ]
}
{
  "scripts": {
    "test:a11y": "pa11y-ci"
  }
}
# GitHub Actions
- name: Accessibility tests
  run: |
    npm start &
    sleep 5
    npx pa11y-ci

Jest + axe-core for Unit-Level Testing

Test HTML fragments or rendered components:

npm install --save-dev jest-axe
// component.test.js
var { axe, toHaveNoViolations } = require("jest-axe");

expect.extend(toHaveNoViolations);

test("user card has no accessibility violations", function() {
  var html = '<div class="user-card">'
    + '<img src="avatar.jpg" alt="User avatar">'
    + '<h3>Shane Developer</h3>'
    + '<p>Full-stack engineer</p>'
    + '<a href="/profile/shane">View Profile</a>'
    + '</div>';

  return axe(html).then(function(results) {
    expect(results).toHaveNoViolations();
  });
});

test("form without labels fails accessibility", function() {
  var html = '<form>'
    + '<input type="text" name="username">'
    + '<input type="email" name="email">'
    + '<button type="submit">Submit</button>'
    + '</form>';

  return axe(html).then(function(results) {
    expect(results.violations.length).toBeGreaterThan(0);
    expect(results.violations[0].id).toBe("label");
  });
});

Keyboard Navigation Testing

Automated tools miss keyboard accessibility issues. Playwright can test keyboard navigation:

test("can navigate form with keyboard", async function({ page }) {
  await page.goto("http://localhost:3000/contact");

  // Tab to the first field
  await page.keyboard.press("Tab");
  var focused = await page.evaluate(function() {
    return document.activeElement.tagName + "#" + document.activeElement.id;
  });
  expect(focused).toBe("INPUT#name");

  // Tab to the next field
  await page.keyboard.press("Tab");
  focused = await page.evaluate(function() {
    return document.activeElement.tagName + "#" + document.activeElement.id;
  });
  expect(focused).toBe("INPUT#email");

  // Tab to submit button
  await page.keyboard.press("Tab");
  await page.keyboard.press("Tab"); // Skip message textarea
  focused = await page.evaluate(function() {
    return document.activeElement.tagName;
  });
  expect(focused).toBe("BUTTON");
});

test("modal can be closed with Escape key", async function({ page }) {
  await page.goto("http://localhost:3000");

  // Open modal
  await page.click(".open-modal-button");
  await page.waitForSelector(".modal", { state: "visible" });

  // Press Escape
  await page.keyboard.press("Escape");

  // Modal should be hidden
  await expect(page.locator(".modal")).not.toBeVisible();
});

test("skip link moves focus to main content", async function({ page }) {
  await page.goto("http://localhost:3000");

  // Tab to the skip link
  await page.keyboard.press("Tab");

  var skipLink = await page.evaluate(function() {
    return document.activeElement.textContent;
  });
  expect(skipLink).toContain("Skip to content");

  // Activate the skip link
  await page.keyboard.press("Enter");

  // Focus should be on the main content area
  var focusedId = await page.evaluate(function() {
    return document.activeElement.id;
  });
  expect(focusedId).toBe("main-content");
});

Custom Accessibility Test Helpers

// a11y-helpers.js
var AxeBuilder = require("@axe-core/playwright").default;

function checkPageAccessibility(page, options) {
  var builder = new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa"]);

  if (options && options.exclude) {
    options.exclude.forEach(function(selector) {
      builder.exclude(selector);
    });
  }

  if (options && options.disableRules) {
    builder.disableRules(options.disableRules);
  }

  return builder.analyze().then(function(results) {
    return {
      violations: results.violations,
      passes: results.passes.length,
      incomplete: results.incomplete.length,
      summary: formatViolations(results.violations)
    };
  });
}

function formatViolations(violations) {
  if (violations.length === 0) return "No violations found";

  var lines = ["Found " + violations.length + " violation(s):"];

  violations.forEach(function(v) {
    lines.push("");
    lines.push("[" + v.impact.toUpperCase() + "] " + v.id + ": " + v.description);
    lines.push("  Help: " + v.helpUrl);
    v.nodes.forEach(function(node) {
      lines.push("  Element: " + node.html.substring(0, 100));
    });
  });

  return lines.join("\n");
}

function expectNoViolations(results) {
  if (results.violations.length > 0) {
    throw new Error(results.summary);
  }
}

module.exports = {
  checkPageAccessibility: checkPageAccessibility,
  expectNoViolations: expectNoViolations
};
var a11y = require("./a11y-helpers");

test("all pages are accessible", async function({ page }) {
  var pages = ["/", "/articles", "/contact", "/about"];

  for (var i = 0; i < pages.length; i++) {
    await page.goto("http://localhost:3000" + pages[i]);
    var results = await a11y.checkPageAccessibility(page);
    a11y.expectNoViolations(results);
  }
});

CI/CD Integration

GitHub Actions Workflow

name: Accessibility Tests
on: [pull_request]

jobs:
  a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
      - run: npm ci
      - run: npx playwright install --with-deps chromium

      - name: Start application
        run: npm start &
        env:
          NODE_ENV: test
      - name: Wait for app
        run: npx wait-on http://localhost:3000

      - name: Run accessibility tests
        run: npx playwright test --grep @a11y

      - name: Run pa11y-ci
        run: npx pa11y-ci

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: a11y-report
          path: test-results/

Pre-Commit Hook

{
  "scripts": {
    "lint:a11y": "pa11y-ci --sitemap http://localhost:3000/sitemap.xml"
  }
}

Common Violations and Fixes

Missing Alt Text

<!-- BAD -->
<img src="hero.jpg">

<!-- GOOD — descriptive alt text -->
<img src="hero.jpg" alt="Developer working at a standing desk with multiple monitors">

<!-- GOOD — decorative image -->
<img src="divider.png" alt="" role="presentation">

Missing Form Labels

<!-- BAD -->
<input type="email" placeholder="Your email">

<!-- GOOD -->
<label for="email">Email address</label>
<input type="email" id="email" placeholder="[email protected]">

<!-- GOOD — visually hidden label -->
<label for="search" class="sr-only">Search articles</label>
<input type="search" id="search" placeholder="Search...">

Insufficient Color Contrast

/* BAD — gray on white fails WCAG AA (ratio 2.8:1) */
.muted-text { color: #999; }

/* GOOD — darker gray passes WCAG AA (ratio 4.6:1) */
.muted-text { color: #767676; }

/* GOOD — even darker for small text (ratio 7:1) */
.small-text { color: #595959; }

Missing Page Language

<!-- BAD -->
<html>

<!-- GOOD -->
<html lang="en">

Empty Links

<!-- BAD -->
<a href="/profile"><i class="icon-user"></i></a>

<!-- GOOD -->
<a href="/profile" aria-label="User profile"><i class="icon-user"></i></a>

<!-- GOOD -->
<a href="/profile"><i class="icon-user" aria-hidden="true"></i> Profile</a>

Common Issues and Troubleshooting

axe-core reports no violations but users report accessibility issues

Automated tools catch 30-50% of accessibility issues. Manual testing is still required:

Fix: Combine automated testing with manual testing. Test with a screen reader (NVDA on Windows, VoiceOver on Mac). Test keyboard navigation manually. Include users with disabilities in usability testing.

Too many violations to fix at once

A legacy application may have hundreds of violations:

Fix: Prioritize by impact level: critical and serious first, then moderate and minor. Fix violations in new code immediately. Dedicate a small percentage of each sprint to fixing existing violations. Use disableRules to suppress known issues with tracked tickets.

Tests pass but pa11y finds different violations

Different tools use different rule sets and versions of axe-core:

Fix: Standardize on one tool for CI gating. Use others for additional insight. Ensure all tools target the same WCAG level (AA is the standard target).

Dynamic content is not tested

Modals, dropdowns, and AJAX-loaded content are not present when the page first loads:

Fix: Write tests that interact with the page — click buttons to open modals, trigger dropdowns, wait for dynamic content to load — then run accessibility checks. Test each state of the UI, not just the initial load.

Best Practices

  • Start with automated testing and iterate toward manual testing. Automated tools catch the low-hanging fruit. Add manual screen reader testing and keyboard testing as your accessibility practice matures.
  • Test every page, not just the homepage. Forms, error states, modals, and interactive components often have the worst accessibility. Test the pages users actually interact with.
  • Fix violations in new code immediately. Do not add to the backlog. If a PR introduces an accessibility violation, fix it before merging. Prevention is cheaper than remediation.
  • Include accessibility checks in CI. Run axe-core or pa11y in your pipeline. Fail the build on critical violations. This prevents regressions.
  • Test interactive states. A form might be accessible in its default state but inaccessible when showing validation errors. Test all states: empty, filled, error, success, loading.
  • Use semantic HTML before ARIA. A <button> is accessible by default. A <div onclick> needs ARIA roles, keyboard handlers, and focus management. Semantic HTML does the heavy lifting.
  • Track violations as technical debt. Each suppressed rule or excluded element should have a ticket. Review the list quarterly and reduce it.
  • Learn from violations. Each accessibility bug is a learning opportunity. Share common patterns with the team to prevent recurrence.

References

Powered by Contentful