Skip to main content

Playwright a11y testing: @axe-core integration

Article from the Playwright Component Testing Avanzato series. Published November 26, 2027.

What do you think about this article?

Playwright a11y testing: @axe-core integration
Playwright ComponentsPhotos:panumas nikhomkhaiPexels

At a Glance

Article from the Playwright Component Testing Avanzato series. Published November 26, 2027.

Introduction to Accessibility Testing with Playwright

Accessibility testing is a fundamental aspect of modern web development. It's not just a legal or moral requirement — it's a technical imperative that concerns the inclusivity and reachability of your digital products. In this guide from the Playwright Component Testing Avanzato series, you'll discover how to automate accessibility testing using Playwright integrated with @axe-core, the most widespread open-source library for detecting accessibility vulnerabilities in your code.

We'll cover the integration step by step, learn to write meaningful tests, and identify best practices to ensure your components are accessible to all users — regardless of their physical or cognitive abilities.

Why Accessibility Testing is Critical

According to global statistics, approximately 16% of the world's population lives with some form of disability. For websites, this means:

📊 Impact of Accessibility

  • Legal compliance: Regulations like WCAG 2.1 and accessibility laws require compliance
  • Wider market: Reach users with screen readers, keyboards, adaptive controllers
  • Better SEO: Accessibility practices align with SEO best practices
  • Maintainability: Accessible code is generally cleaner and better-structured code
  • Brand reputation: Demonstrates commitment to inclusivity

Initial Setup: Playwright + @axe-core

To get started, you need Playwright and the official library for integrating axe-core.

Bash - Installing dependencies
npm install --save-dev @playwright/test @axe-core axe-playwright

The axe-playwright package provides direct integration between Playwright and axe-core, simplifying injection and test execution.

Basic Structure of an Accessibility Test

Here's the minimal structure for an accessibility test:

TypeScript - Base test with axe-core
import { test, expect } from '@playwright/test';
import { injectAxe, checkA11y, getViolations } from 'axe-playwright';

test('home page should pass accessibility checks', async ({ page }) => {
  // Navigation
  await page.goto('http://localhost:4200');

  // Inject axe
  await injectAxe(page);

  // Run check
  await checkA11y(page, null, {
    detailedReport: true,
    detailedReportOptions: {
      html: true,
    },
  });
});

Targeting and Specific Scopes

Sometimes you want to check only part of the page. Use the selector parameter to limit the scope:

TypeScript - Test with limited scope
import { test } from '@playwright/test';
import { injectAxe, checkA11y } from 'axe-playwright';

test('navbar should pass accessibility checks', async ({ page }) => {
  await page.goto('http://localhost:4200');
  await injectAxe(page);

  // Test only the navbar
  await checkA11y(page, 'nav[role="navigation"]', {
    detailedReport: true,
  });
});

Advanced Results Management

Not all accessibility issues are critical. You can customize how you handle results:

TypeScript - Granular result handling
import { test, expect } from '@playwright/test';
import { injectAxe, getViolations } from 'axe-playwright';

test('component with custom violation handling', async ({ page }) => {
  await page.goto('http://localhost:4200/components/button');
  await injectAxe(page);

  // Get violations without failing the test
  const violations = await getViolations(page);

  // Filter only critical ones
  const criticalViolations = violations.filter(v => v.impact === 'critical');

  // Detailed logging
  if (criticalViolations.length > 0) {
    console.log('Critical violations found:');
    criticalViolations.forEach(v => {
      console.log(`- ${v.id}: ${v.description}`);
      v.nodes.forEach(n => {
        console.log(`  Element: ${n.html}`);
        console.log(`  Message: ${n.failureSummary}`);
      });
    });
  }

  // Expectation
  expect(criticalViolations).toHaveLength(0);
});

Tip: Excluding Known Violations

Sometimes a violation is a false-positive or intentional. You can exclude it:

TypeScript - Violation exclusion
await checkA11y(page, null, {
  rules: {
    'color-contrast': { enabled: false }, // Disable contrast check
    'aria-required-attr': { enabled: false },
  },
});

Testing Interactive Components

Many components require interaction. Test accessibility after every state change:

TypeScript - Modal dialog component test
test('modal dialog should be accessible in all states', async ({ page }) => {
  await page.goto('http://localhost:4200/components/modal');
  await injectAxe(page);

  // Test initial state (modal closed)
  await checkA11y(page, null, { detailedReport: true });

  // Open modal
  await page.click('button:has-text("Open Modal")');
  await page.waitForSelector('[role="dialog"]');

  // Test modal open state
  await checkA11y(page, '[role="dialog"]', { detailedReport: true });

  // Focus trap test (focus must not escape modal)
  await page.press('[role="dialog"]', 'Tab');
  const focusedElement = await page.evaluate(() => document.activeElement?.tagName);
  expect(['BUTTON', 'INPUT', 'A']).toContain(focusedElement);

  // Close with Escape
  await page.press('[role="dialog"]', 'Escape');
  await page.waitForSelector('[role="dialog"]', { state: 'hidden' });

  // Verify focus returned to button
  const returnFocus = await page.evaluate(() => document.activeElement?.textContent);
  expect(returnFocus).toContain('Open Modal');
});

Test Suite Level Automation

You can create a reusable helper that applies accessibility tests across multiple components:

TypeScript - Reusable helper
import { Page } from '@playwright/test';
import { injectAxe, checkA11y, getViolations } from 'axe-playwright';

export async function testComponentAccessibility(
  page: Page,
  componentUrl: string,
  selector?: string
): Promise {
  await page.goto(componentUrl);
  await injectAxe(page);

  const violations = await getViolations(page);
  const critical = violations.filter(v => v.impact === 'critical');

  if (critical.length > 0) {
    throw new Error(
      `${critical.length} critical a11y violations found on ${componentUrl}`
    );
  }

  await checkA11y(page, selector || null, { detailedReport: true });
}

// Usage
test('all component pages pass a11y', async ({ page }) => {
  const components = [
    '/components/button',
    '/components/input',
    '/components/select',
    '/components/dropdown',
  ];

  for (const comp of components) {
    await testComponentAccessibility(page, comp);
  }
});

Best Practices for Accessibility Testing

Here are the recommended practices to apply in your tests:

✅ Best Practices

  • Test early and often: Include a11y tests from the development phase
  • Integrate with CI/CD: Fail the build if critical violations are detected
  • Test without false expectations: Don't disable rules arbitrarily; understand why
  • Vary tests across devices: Test both keyboard navigation and screen reader
  • Document exclusions: If you ignore a violation, explain the reason in your code
  • Maintain access data: Use reports to track improvements over time

CI/CD Configuration for Accessibility

Integrate tests into your build pipeline to ensure no commit degrades accessibility:

YAML - GitHub Actions
name: Accessibility Tests
on: [push, pull_request]

jobs:
  a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm ci
      - run: npm run build
      - run: npm run test:a11y
      - name: Upload a11y report
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: a11y-report
          path: ./a11y-reports/

Common Troubleshooting

Here are the most frequently encountered issues and how to resolve them:

🔧 Common Issues

  • axe won't inject: Verify the page is fully loaded before injectAxe()
  • False positives on dynamic elements: Add await page.waitForLoadState('networkidle') before tests
  • Screen reader doesn't detect content: Use role and aria-label correctly
  • Flaky tests: Add waitForSelector before testing dynamic components

FAQ - Frequently Asked Questions

Q: What's the difference between axe-core and WAVE?

axe-core is a free open-source library that's programmatically testable. WAVE is a browser tool primarily manual (though it has an API). For automation, axe-core is the best choice.

Q: Can I use axe-core with non-Playwright tests?

Yes! axe-core works with Cypress, Selenium, Puppeteer, and even directly in the browser via console. Each framework has its own adapter.

Q: If I have a legacy site with a11y issues, where do I start?

Start with critical issues (contrast, form labels). Use `getViolations()` to sort by impact. Address WCAG AA criteria first, then AAA. It's not a race; it's continuous improvement.

Q: Should I test every component individually?

It depends on complexity. For simple components (button, input), integration testing on the page is enough. For complex components (table, tree, modal), test each state separately.

Q: Does axe-core cover everything in WCAG 2.1?

No. axe-core covers approximately 30-40% of WCAG 2.1 criteria automatically. The rest requires manual testing (zoom, keyboard navigation, screen reader) and human review.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects