Les tests d'accessibilité vérifient que votre application fonctionne pour les utilisateurs en situation de handicap. Cela inclut ceux qui utilisent des lecteurs d'écran, la navigation au clavier, ou les modes de contraste élevé. Playwright s'intègre avec axe-core (le moteur d'accessibilité de référence dans l'industrie) pour automatiser les vérifications a11y aux côtés de vos tests habituels.

Pourquoi automatiser les tests d'accessibilité

Les audits d'accessibilité manuels sont lents et coûteux. Les vérifications automatisées détectent les problèmes les plus courants instantanément :

  • Texte alternatif manquant sur les images
  • Champs de formulaire sans labels
  • Contraste de couleur insuffisant
  • Rôles et attributs ARIA manquants
  • Problèmes de navigation au clavier
  • Problèmes de gestion du focus

Les outils automatisés détectent environ 30 à 40% des problèmes d'accessibilité. Le reste nécessite des tests manuels avec de vraies technologies d'assistance. Mais détecter 30 à 40% automatiquement, à chaque commit, est bien meilleur que rien.

Installation : axe-playwright

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

Scan d'accessibilité basique

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test("la page d'accueil n'a pas de violations d'accessibilité", async ({ page }) => {
  await page.goto('/');
  
  const results = await new AxeBuilder({ page }).analyze();
  
  expect(results.violations).toEqual([]);
});

Si des violations existent, le test échoue avec les détails :

● la page d'accueil n'a pas de violations d'accessibilité

  expect(received).toEqual(expected)
  
  Expected: []
  Received: [
    {
      id: 'color-contrast',
      description: 'Elements must have sufficient color contrast',
      nodes: [{ target: ['#nav-link'], ... }]
    }
  ]

Scanner des pages spécifiques

test.describe('Vérifications d\'accessibilité', () => {
  const pages = [
    { name: 'Accueil', url: '/' },
    { name: 'Connexion', url: '/login' },
    { name: 'Produits', url: '/products' },
    { name: 'Contact', url: '/contact' },
  ];

  for (const { name, url } of pages) {
    test(`La page ${name} est accessible`, async ({ page }) => {
      await page.goto(url);
      await page.waitForLoadState('networkidle');
      
      const results = await new AxeBuilder({ page }).analyze();
      expect(results.violations).toEqual([]);
    });
  }
});

Filtrer les règles

Exécutez uniquement des critères WCAG spécifiques, ou excluez les problèmes connus :

test('page de connexion conforme WCAG AA', async ({ page }) => {
  await page.goto('/login');
  
  const results = await new AxeBuilder({ page })
    // Vérifier uniquement les critères WCAG 2.1 AA
    .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
    .analyze();
  
  expect(results.violations).toEqual([]);
});

test('page produits avec exclusion des problèmes connus', async ({ page }) => {
  await page.goto('/products');
  
  const results = await new AxeBuilder({ page })
    // Exclure temporairement un problème connu en cours de correction
    .disableRules(['color-contrast'])
    .analyze();
  
  expect(results.violations).toEqual([]);
});

Scanner une partie de la page

test('le menu de navigation est accessible', async ({ page }) => {
  await page.goto('/');
  
  const results = await new AxeBuilder({ page })
    .include('#main-navigation')  // Scanner uniquement la nav
    .analyze();
  
  expect(results.violations).toEqual([]);
});

test('la boîte de dialogue modale est accessible', async ({ page }) => {
  await page.goto('/products');
  await page.click('[data-testid="add-to-cart"]');
  
  // Attendre l'ouverture de la modale
  await page.waitForSelector('[role="dialog"]');
  
  const results = await new AxeBuilder({ page })
    .include('[role="dialog"]')
    .analyze();
  
  expect(results.violations).toEqual([]);
});

Test de navigation au clavier

axe détecte les attributs ARIA manquants. Les tests clavier manuels trouvent les problèmes de flux de navigation :

test('le formulaire de connexion est navigable au clavier', async ({ page }) => {
  await page.goto('/login');
  
  // Tabber à travers les champs du formulaire
  await page.keyboard.press('Tab');
  await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'email-input');
  
  await page.keyboard.press('Tab');
  await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'password-input');
  
  await page.keyboard.press('Tab');
  await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'submit-btn');
  
  // Soumettre avec la touche Entrée
  await page.keyboard.press('Enter');
  await page.waitForURL('/dashboard');
});

test('la modale peut être fermée avec Échap', async ({ page }) => {
  await page.goto('/products');
  await page.click('[data-testid="filter-btn"]');
  
  await expect(page.getByRole('dialog')).toBeVisible();
  
  await page.keyboard.press('Escape');
  
  await expect(page.getByRole('dialog')).not.toBeVisible();
});

Gestion du focus

Quand les modales s'ouvrent, le focus doit se déplacer à l'intérieur. Quand elles se ferment, il doit revenir à l'élément déclencheur :

test('la modale piège correctement le focus', async ({ page }) => {
  await page.goto('/');
  
  // Ouvrir la modale
  const triggerButton = page.getByTestId('open-modal');
  await triggerButton.click();
  
  const modal = page.getByRole('dialog');
  await expect(modal).toBeVisible();
  
  // Le focus doit être à l'intérieur de la modale
  const focusedElement = page.locator(':focus');
  await expect(modal).toContainElement(focusedElement);
  
  // Tabber dans la modale — le focus ne doit pas s'échapper
  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab');
  
  // Toujours à l'intérieur de la modale
  await expect(modal).toContainElement(page.locator(':focus'));
  
  // Fermer la modale
  await page.keyboard.press('Escape');
  
  // Le focus retourne au bouton déclencheur
  await expect(triggerButton).toBeFocused();
});

Vérification du texte alternatif des images

test('toutes les images de produits ont un texte alternatif', async ({ page }) => {
  await page.goto('/products');
  
  // Trouver toutes les images
  const images = page.locator('img');
  const count = await images.count();
  
  for (let i = 0; i < count; i++) {
    const img = images.nth(i);
    const alt = await img.getAttribute('alt');
    const src = await img.getAttribute('src');
    
    // L'alt doit exister et ne pas être vide (sauf pour les images décoratives avec role="presentation")
    const role = await img.getAttribute('role');
    if (role !== 'presentation') {
      expect(alt, `Image ${src} n'a pas de texte alternatif`).not.toBeNull();
      expect(alt, `Image ${src} a un texte alternatif vide`).not.toBe('');
    }
  }
});

Rôles et labels ARIA

test('les champs de formulaire ont des labels', async ({ page }) => {
  await page.goto('/contact');
  
  const inputs = page.locator('input, textarea, select');
  const count = await inputs.count();
  
  for (let i = 0; i < count; i++) {
    const input = inputs.nth(i);
    const type = await input.getAttribute('type');
    
    // Ignorer les champs cachés
    if (type === 'hidden') continue;
    
    const id = await input.getAttribute('id');
    const ariaLabel = await input.getAttribute('aria-label');
    const ariaLabelledBy = await input.getAttribute('aria-labelledby');
    
    // Le champ doit avoir un label (via id+label, aria-label, ou aria-labelledby)
    const hasLabel = id ? await page.locator(`label[for="${id}"]`).count() > 0 : false;
    
    const isLabelled = hasLabel || ariaLabel || ariaLabelledBy;
    expect(isLabelled, `Champ sans label : ${id || 'sans nom'}`).toBeTruthy();
  }
});

Générer des rapports HTML accessibles

import AxeBuilder from '@axe-core/playwright';

// Helper personnalisé qui formate les violations lisiblement
async function checkAccessibility(page, selector?: string) {
  const builder = new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa']);
  
  if (selector) builder.include(selector);
  
  const results = await builder.analyze();
  
  if (results.violations.length > 0) {
    const report = results.violations.map(v => 
      `\n[${v.impact?.toUpperCase()}] ${v.id}: ${v.description}\n` +
      v.nodes.map(n => `  - ${n.target.join(', ')}: ${n.failureSummary}`).join('\n')
    ).join('\n');
    
    throw new Error(`Violations d'accessibilité trouvées :\n${report}`);
  }
}

test('le tableau de bord est accessible', async ({ page }) => {
  await page.goto('/dashboard');
  await checkAccessibility(page);
});

Problèmes d'accessibilité courants détectés en automatisation

| Problème | Règle axe | Correction |

|----------|-----------|------------|

| Image sans alt | image-alt | Ajouter alt="description" |

| Contraste insuffisant | color-contrast | Ratio de contraste ≥ 4,5:1 |

| Champ sans label | label | Ajouter ou aria-label |

| Bouton sans texte | button-name | Ajouter du texte ou aria-label |

| Ordre de titres | heading-order | Ne pas sauter h1 vers h3 |

| Lang manquant | html-has-lang | Ajouter |

| Lien sans nom | link-name | Ajouter un texte de lien descriptif |

Récapitulatif

# Installation
npm install --save-dev @axe-core/playwright

# Utilisation basique
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);

# Filtrer par niveau WCAG
.withTags(['wcag2a', 'wcag2aa'])

# Limiter à un élément
.include('#main-nav')

# Exclure un problème connu
.disableRules(['color-contrast'])

Lancez des vérifications d'accessibilité sur vos pages critiques de la même façon que vos tests fonctionnels : à chaque PR, automatiquement. Combiné avec les tests de navigation clavier et les vérifications de gestion du focus, vous détectez la majorité des problèmes avant qu'ils n'atteignent les utilisateurs qui dépendent des technologies d'assistance.

→ See also: Tests d'Accessibilité pour Ingénieurs QA: Outils, Techniques et la Date Limite EAA 2025 | Événements Clavier et Souris dans Playwright | Tests de Régression Visuelle par IA: Au-delà des Captures Pixel-Parfaites