brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
automated-a11y-testing-axe-coreImplements automated accessibility testing across the testing pyramid using axe-core engine with jest-axe, @axe-core/playwright, pa11y-ci for unit/E2E/CI integration and WCAG violation detection.
Independent GitHub snapshot · Automated classification · Original documentation governs
Implements automated accessibility testing using the axe-core engine across the entire testing pyramid: unit tests (jest-axe), end-to-end tests (@axe-core/playwright), and CI/CD integration (pa11y-ci). This skill covers configuration, rule tags, assertions, baselines for regression detection, and common violation patterns. Load when setting up accessibility testing, adding A11y checks to test suites, or integrating continuous accessibility validation.
npm install -D axe-core jest-axe @axe-core/playwright pa11y-ciexpect.extend(toHaveNoViolations)toHaveNoViolations()Use this skill when:
Avoid this skill for:
Install Dependencies — Add axe-core, jest-axe, @axe-core/playwright, pa11y-ci to dev dependencies. Verify versions support your Jest/Playwright versions.
Configure Rule Set — Choose rule set (wcag2a, wcag21aa, wcag22aa) based on compliance target. Tag configuration determines which violations are caught. AA is standard baseline.
Unit Test Components — Create jest-axe tests for each component. Arrange component state, scan with axe, assert no violations. Test multiple states (hover, disabled, error, loading).
E2E Page Tests — Add Playwright tests with AxeBuilder. Test full pages, not just components. Specify which rules to check (wcag21aa standard).
Establish Baseline — Run full test suite, capture violations that are expected/acceptable. Create baseline snapshot or JSON report.
Configure CI Gate — Set up GitHub Actions (or CI tool) to run pa11y-ci on all URLs. Compare against baseline. Fail pipeline on new violations.
Document Exclusions — For false positives, tag with data-testid or CSS selector for exclusion. Document why exclusion exists. Review exclusions in sprint planning.
Monitor and Report — Track A11y test results over time. Report violations metrics (active violations, fixed in last sprint, remediation backlog). Use dashboard to track progress.
▲
│ Integration Tests (multi-page flows)
│ pa11y-ci scanning all URLs
│
│ E2E Tests (AxeBuilder on full pages)
│ @axe-core/playwright scanning user interactions
│
│ Unit Tests (jest-axe on components)
│ Each component scanned in isolation
│
└────────────────────────────────────
Goal: Catch accessibility violations as early as possible in development.
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
// Register jest-axe matchers
expect.extend(toHaveNoViolations);
describe('AccessibleButton Component', () => {
// Test 1: Component renders without a11y violations
it('should not have accessibility violations in default state', async () => {
const { container } = render(
<button aria-label="Add item">
<svg aria-hidden="true">+</svg>
</button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Test 2: Disabled state
it('should not have violations when disabled', async () => {
const { container } = render(
<button disabled aria-label="Add item">
<svg aria-hidden="true">+</svg>
</button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Test 3: With error state
it('should not have violations with error state', async () => {
const { container } = render(
<button aria-label="Add item" aria-invalid="true">
<svg aria-hidden="true">!</svg>
</button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Test 4: Specific rule check (focus-visible)
it('should have visible focus indicator', async () => {
const { container } = render(
<button aria-label="Add item">+</button>
);
const results = await axe(container, {
rules: {
'focus-visible': { enabled: true },
},
});
expect(results).toHaveNoViolations();
});
});
// Form field with label
describe('AccessibleFormField Component', () => {
it('should have proper label association', async () => {
const { container } = render(
<div>
<label htmlFor="email-input">Email Address</label>
<input
id="email-input"
type="email"
aria-required="true"
placeholder="you@example.com"
/>
</div>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have accessible error messages', async () => {
const errorId = 'email-error';
const { container } = render(
<div>
<label htmlFor="email-input">Email Address</label>
<input
id="email-input"
type="email"
aria-invalid="true"
aria-describedby={errorId}
/>
<p id={errorId} role="alert">
Please enter a valid email
</p>
</div>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Key patterns:
await axe(container) to scan the componentrules option to enable/disable specific checksimport { test, expect } from '@playwright/test';
import { injectAxe, checkA11y } from 'axe-playwright';
test.describe('HomePage Accessibility', () => {
// Full page scan
test('should not have a11y violations on homepage', async ({ page }) => {
await page.goto('http://localhost:3000');
// Inject axe-core into page
await injectAxe(page);
// Check a11y violations (default: wcag2aa standard)
await checkA11y(page, null, {
rules: {
'color-contrast': { enabled: true },
'heading-order': { enabled: true },
},
});
});
// Scan specific region (exclude certain elements)
test('should scan main content area only', async ({ page }) => {
await page.goto('http://localhost:3000/dashboard');
await injectAxe(page);
// Scan only main, exclude sidebar
await checkA11y(page, 'main', {
exclude: ['aside', '[data-testid="cookie-banner"]'],
});
});
// Interactive scan (test after user actions)
test('should have no violations after opening dialog', async ({ page }) => {
await page.goto('http://localhost:3000');
await injectAxe(page);
// User opens dialog
await page.click('button[aria-label="Open settings"]');
await page.waitForSelector('[role="dialog"]');
// Scan updated DOM
await checkA11y(page, '[role="dialog"]', {
rules: {
'focus-visible': { enabled: true },
'button-name': { enabled: true },
},
});
});
// Scan multiple states
test('should check form validation states', async ({ page }) => {
await page.goto('http://localhost:3000/form');
await injectAxe(page);
// Scan initial state
await checkA11y(page, 'form');
// Fill invalid email, trigger validation
await page.fill('input[type="email"]', 'invalid');
await page.click('button[type="submit"]');
await page.waitForSelector('[role="alert"]');
// Scan error state
await checkA11y(page, 'form', {
rules: {
'color-contrast': { enabled: true }, // Ensure error color passes contrast
},
});
});
});
// Using AxeBuilder (alternative API)
test('alternative AxeBuilder API', async ({ page }) => {
await page.goto('http://localhost:3000');
const results = await new AxeBuilder({ page })
.withTags(['wcag21aa', 'wcag22aa'])
.exclude('[data-testid="third-party-widget"]')
.analyze();
expect(results.violations).toEqual([]);
});
Key patterns:
injectAxe(page) then checkA11y(page) or use AxeBuilder// .pa11yci.json — Configuration for multi-URL scanning
{
"runners": ["axe"],
"standard": "WCAG2AA",
"timeout": 10000,
"wait": 500,
"chromeLaunchConfig": {
"args": ["--disable-gpu", "--no-sandbox"]
},
"urls": [
"http://localhost:3000/",
"http://localhost:3000/about",
"http://localhost:3000/contact",
"http://localhost:3000/products",
"http://localhost:3000/checkout",
"http://localhost:3000/account"
],
"headless": true,
"includeNotices": false,
"includeWarnings": false,
"suppressStderr": false,
"strict": true,
"bail": true,
"reporters": ["json", "csv"],
"outputDir": "./a11y-reports"
}
# Run pa11y-ci locally
pa11y-ci --config .pa11yci.json
# Run against live staging URL
pa11y-ci --config .pa11yci.json --base-url https://staging.example.com
# Generate baseline (first run, commit as reference)
pa11y-ci --config .pa11yci.json --save-baseline
# Compare against baseline (fail if new violations)
pa11y-ci --config .pa11yci.json --check-baseline
GitHub Actions Workflow:
name: Accessibility Tests
on:
pull_request:
push:
branches: [main]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Start dev server
run: npm run dev &
env:
CI: true
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run jest-axe tests
run: npm run test:a11y
- name: Run pa11y-ci
run: npx pa11y-ci --config .pa11yci.json --check-baseline
- name: Upload a11y report
if: always()
uses: actions/upload-artifact@v3
with:
name: a11y-reports
path: a11y-reports/
- name: Comment PR with results
if: always()
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
const report = JSON.parse(fs.readFileSync('a11y-reports/results.json'));
const violations = report.results.reduce((sum, r) => sum + r.violations.length, 0);
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `🔍 Accessibility Test Results\n\nViolations: ${violations}\n\n[View detailed report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})`
});
// jest.setup.js - Configure jest-axe with default rules
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
// Define globally excluded rules and elements
beforeEach(() => {
// Mock axe rules to exclude known false positives
axe.configure({
checks: [
{
id: 'color-contrast',
enabled: true,
options: {
// Exclude decorative elements
ignoreElements: ['.decorative-icon', '[aria-hidden="true"]'],
},
},
],
});
});
// Test file with specific exclusions
describe('ThirdPartyWidget', () => {
it('should not have violations (excluding inaccessible widget)', async () => {
const { container } = render(
<div>
<MyAccessibleComponent />
<ExternalWidget id="external-not-accessible" />
</div>
);
// Exclude third-party widget from scan
const results = await axe(container, {
exclude: ['#external-not-accessible'],
});
expect(results).toHaveNoViolations();
});
});
// Baseline snapshot approach
describe('HeaderComponent - Visual Regression + A11y', () => {
it('should match baseline and have no violations', async () => {
const { container } = render(<Header />);
// Visual snapshot
expect(container).toMatchSnapshot();
// A11y snapshot
const results = await axe(container);
expect(results.violations).toMatchSnapshot('a11y-violations');
});
});
axe-core rules are tagged for filtering. Use tags in tests to control which rules apply:
| Tag | Scope | Usage |
|---|---|---|
wcag2a | WCAG 2.0/2.1 Level A | Basic accessibility |
wcag2aa | WCAG 2.0/2.1 Level AA | Standard baseline |
wcag21a | WCAG 2.1-specific Level A | 2.1 additions |
wcag21aa | WCAG 2.1-specific Level AA | Recommended for 2024 |
wcag22a | WCAG 2.2 Level A | New success criteria |
wcag22aa | WCAG 2.2 Level AA | Forward-looking (2024+) |
section508 | Section 508 compliance | U.S. federal |
best-practice | Best practices beyond standards | Optional improvements |
Recommended configuration:
// Use wcag21aa as standard baseline
const defaultRules = {
rules: {
// Include WCAG 2.1 AA
'wcag21aa': { enabled: true },
// Include best practices
'best-practice': { enabled: false }, // Or true if stricter
},
};
Violation: Input fields without associated labels.
Fix:
<!-- ❌ BAD -->
<input type="email" placeholder="Enter email" />
<!-- ✅ GOOD -->
<label htmlFor="email">Email Address</label>
<input id="email" type="email" />
Violation: Text-background contrast < 4.5:1 (AA) or < 3:1 (large text).
Fix:
/* ❌ BAD: 2.8:1 contrast — fails AA */
.text { color: #94a3b8; background: white; }
/* ✅ GOOD: 7.0:1 contrast — passes AA */
.text { color: #475569; background: white; }
Violation: Images without alt text.
Fix:
<!-- ❌ BAD -->
<img src="profile.jpg" />
<!-- ✅ GOOD -->
<img src="profile.jpg" alt="User profile photo of Jane Doe" />
<!-- Decorative images -->
<img src="decorative-line.svg" alt="" aria-hidden="true" />
Violation: Interactive elements without visible focus indicator.
Fix:
/* ✅ GOOD: Visible focus indicator */
button:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
/* Avoid removing focus */
button:focus {
outline: 2px solid #2563eb;
/* Never: outline: none; */
}
Violation: Heading hierarchy skips levels (h1 → h3, missing h2).
Fix:
<!-- ❌ BAD: Skips h2 -->
<h1>Title</h1>
<h3>Subsection</h3>
<!-- ✅ GOOD: Proper hierarchy -->
<h1>Title</h1>
<h2>Subsection</h2>
<h3>Detail</h3>
data-testid or selector)Setup Phase:
expect.extend(toHaveNoViolations)E2E Phase:
CI/CD Phase:
Monitoring Phase:
axe-core occasionally flags issues that are actually accessible. Common false positives:
exclude to skip.Solution: Use exclude in jest or pa11y config. Document rationale.
Large test suites with many axe scans slow down tests:
Solution:
{ rules: { 'best-practice': { enabled: false } } } to reduce overheadaxe scans sometimes catch violations that appear/disappear:
Solution:
After establishing automated testing baseline: