Accessibility testing with JavaScript
Automated accessibility testing catches issues before they reach production. JavaScript libraries like axe-core integrate directly into your existing test suite — unit tests, integration tests, and end-to-end tests.
axe-core — the foundation
Most JavaScript accessibility testing tools are built on axe-core. It runs WCAG 2.1 checks against rendered DOM and returns structured violations.
Standalone usage
import axe from 'axe-core';
axe.run(document, {
runOnly: ['wcag2a', 'wcag2aa']
}).then(results => {
if (results.violations.length > 0) {
results.violations.forEach(violation => {
console.error(`[${violation.impact}] ${violation.description}`);
violation.nodes.forEach(node => {
console.error(` Element: ${node.html}`);
console.error(` Fix: ${node.failureSummary}`);
});
});
}
});Running on specific elements
axe.run(document.getElementById('login-form'), {
rules: {
'color-contrast': { enabled: true },
'label': { enabled: true },
'autocomplete-valid': { enabled: true }
}
}).then(results => {
console.log(`${results.violations.length} violations found`);
});Jest + jest-axe
Add accessibility checks to your React, Vue, or Angular component tests :
npm install --save-dev jest-axeimport { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('LoginForm', () => {
it('should have no accessibility violations', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});Testing specific rules
it('should have proper form labels', async () => {
const { container } = render(<ContactForm />);
const results = await axe(container, {
rules: {
'label': { enabled: true },
'input-button-name': { enabled: true }
}
});
expect(results).toHaveNoViolations();
});Testing dynamic states
it('should remain accessible after form submission error', async () => {
const { container, getByText } = render(<LoginForm />);
fireEvent.click(getByText('Submit'));
await waitFor(() => {
expect(getByText('Email is required')).toBeInTheDocument();
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});Playwright accessibility testing
Playwright has built-in axe-core integration through @axe-core/playwright :
npm install --save-dev @axe-core/playwrightimport { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Homepage accessibility', () => {
test('should not have any automatically detectable violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
});Testing specific page sections
test('navigation should be accessible', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.include('nav')
.analyze();
expect(results.violations).toEqual([]);
});Testing keyboard navigation
test('should navigate through cards with keyboard', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
const skipLink = await page.locator(':focus');
await expect(skipLink).toHaveText('Skip to main content');
await page.keyboard.press('Tab');
const firstCard = await page.locator(':focus');
await expect(firstCard).toHaveAttribute('href');
});Testing focus management
test('focus should move to heading after navigation', async ({ page }) => {
await page.goto('/');
await page.click('a[href="/about"]');
const focused = await page.evaluate(() => {
return {
tag: document.activeElement.tagName,
text: document.activeElement.textContent
};
});
expect(focused.tag).toBe('H1');
});Cypress + cypress-axe
npm install --save-dev cypress-axeimport 'cypress-axe';
describe('Product page', () => {
beforeEach(() => {
cy.visit('/products');
cy.injectAxe();
});
it('has no detectable accessibility violations', () => {
cy.checkA11y();
});
it('product cards are accessible', () => {
cy.checkA11y('.product-card');
});
it('remains accessible after filtering', () => {
cy.get('#category-filter').select('Electronics');
cy.checkA11y();
});
});Custom violation handling
cy.checkA11y(null, null, (violations) => {
violations.forEach(violation => {
cy.log(`[${violation.impact}] ${violation.id}: ${violation.description}`);
violation.nodes.forEach(node => {
cy.log(` → ${node.html}`);
});
});
});ESLint — catch issues at write time
Catch accessibility issues in JSX/TSX as you type :
npm install --save-dev eslint-plugin-jsx-a11y{
"plugins": ["jsx-a11y"],
"extends": ["plugin:jsx-a11y/recommended"],
"rules": {
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/alt-text": "error",
"jsx-a11y/label-has-associated-control": "error",
"jsx-a11y/no-autofocus": "warn"
}
}This catches errors like missing alt attributes and invalid ARIA props before the code even runs.
CI/CD integration
Add accessibility checks to your pipeline :
name: Accessibility Tests
on: [push, pull_request]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run build
- run: npx serve -l 3000 dist &
- run: npx wait-on http://localhost:3000
- run: npm run test:a11yWhat automated tests catch vs miss
| Catches | Misses |
|---|---|
| Missing alt text | Whether alt text is meaningful |
| Missing form labels | Whether labels make sense |
| Color contrast ratios | Contrast in custom themes or hover states |
| Missing ARIA attributes | Whether ARIA is used correctly in context |
| Duplicate IDs | Logical tab order |
| Empty links/buttons | Whether content updates are announced |
Rule of thumb : Automated tests cover ~40% of WCAG criteria. Always supplement with manual screen reader testing.
Resources
If you found this helpful, share it with someone who's building for the web.