Tests d’accessibilité Playwright avec axe : états d’interface
Testez dialogues, validation et navigation avec Playwright et axe : exemples exécutables, contrôle du focus, CI et limites de couverture.
Les tests d’accessibilité avec Playwright sont surtout utiles lorsqu’un test atteint l’état d’interface dont une personne a besoin : une boîte de dialogue ouverte, une soumission de formulaire rejetée ou une navigation dépliée. Une analyse de la page initiale ne peut pas inspecter des contrôles qui n’ont pas encore été rendus. Playwright pilote l’interaction ; axe-core vérifie le DOM obtenu au regard des règles sélectionnées.1
Ce guide vous fournit une page de test locale exécutable et trois tests, puis explique ce qu’il faut adapter pour une application. Il s’agit d’une méthode de tests de non-régression, et non d’une évaluation de conformité WCAG. L’évaluation manuelle de l’accessibilité et les tests utilisateurs inclusifs restent nécessaires.1
Choisir des états, pas seulement des routes
Commencez par une tâche importante, comme la modification d’une adresse e-mail. Notez le compte et les données de départ, l’action, le résultat attendu et le périmètre de l’analyse. Incluez les parcours d’erreur et de récupération, pas seulement une soumission réussie. Une même route peut contenir plusieurs états testables indépendamment.
Utilisez STATE comme liste de contrôle de planification : sélectionner un état, le déclencher, vérifier le résultat, trier les constats et établir les preuves. Il s’agit d’un moyen mnémotechnique éditorial, pas d’une norme. Le schéma ci-dessous a pour équivalent textuel ces cinq étapes.
Préférez un petit inventaire explicite à un décompte de routes. Pour les paramètres du compte, couvrez l’ouverture et la fermeture de la boîte de dialogue, la saisie invalide et la récupération réussie. Pour la navigation, couvrez les états déplié et replié dans une fenêtre d’affichage étroite. Ajoutez l’authentification, les autorisations, les indicateurs de fonctionnalité et les échecs de chargement lorsqu’ils modifient les contrôles que rencontre l’utilisateur.
Attendre le résultat de l’action
Playwright n’applique pas les mêmes vérifications d’attente à toutes les actions. click() vérifie la visibilité, la stabilité, la réception des événements et l’état activé. fill() vérifie la visibilité, l’état activé et la possibilité d’édition ; il n’exige ni la stabilité ni la réception des événements. Un clic ou une saisie terminés ne prouvent pas que la validation ou le rendu asynchrone sont achevés.2
Avant d’appeler analyze(), utilisez une assertion à nouvelles tentatives automatiques sur le résultat réel : une boîte de dialogue nommée est visible, une erreur apparaît ou un message d’état contient le texte attendu. Évitez les pauses arbitraires. Si votre application charge des options après l’ouverture d’une boîte de dialogue, vérifier uniquement que son conteneur est visible ne suffit pas ; attendez aussi les options ou un état « prêt » documenté.12
Exécuter l’exemple en local
L’exemple utilise @playwright/test 1.58.1 et @axe-core/playwright 4.11.1. Enregistrez les fichiers suivants dans un répertoire vide, avec Node.js et npm disponibles. La configuration utilise une installation existante de Google Chrome ; elle ne télécharge aucun navigateur. Pour utiliser plutôt le Chromium fourni, supprimez channel et exécutez npx playwright install chromium.
npm init -y
npm install --save-dev --save-exact @playwright/test@1.58.1 @axe-core/playwright@4.11.1
npx playwright testExécutez la dernière commande après avoir enregistré les trois fichiers. Conservez le fichier de verrouillage généré dans votre gestionnaire de versions : la version de l’adaptateur ne fige pas à elle seule chaque dépendance transitive. Le 5 octobre 2026, les trois tests ont réussi dans Chrome en local avec axe-core 4.11.4, la dépendance résolue par l’adaptateur. Il s’agissait d’une exécution sur une page de test synthétique, et non d’un test d’un service de comptes en production.
1. Enregistrer fixture.html
La page de test utilise une boîte de dialogue modale native, une validation d’e-mail côté client et une navigation classique à sections dépliables. Son action « Save » modifie uniquement du texte local ; elle n’envoie aucun e-mail et ne stocke rien. La boucle de focus ne gère que les champs et boutons de cette page de test ; une implémentation réutilisable doit tenir compte des autres contrôles ainsi que des éléments masqués ou désactivés. Ce sont des données de test, pas une bibliothèque de composants de production.
<!doctype html><html lang="en"><title>Account settings fixture</title>
<style>body { color:#111; background:#fff; font:18px sans-serif } :focus-visible {outline:3px solid #005fcc} dialog {color:#111;background:#fff} button, input, a {font:inherit; min-height:32px; margin:4px} a {display:inline-block; padding:4px}</style>
<main><h1>Account settings</h1>
<button id="open">Edit email</button>
<dialog aria-labelledby="heading" aria-modal="true">
<h2 id="heading">Edit email</h2><form novalidate>
<label for="email">Email</label><input id="email" type="email" autocomplete="email" autofocus>
<p id="error" hidden>Enter a valid email address.</p>
<button type="submit">Save</button><button type="button" id="close">Cancel</button>
</form></dialog>
<button id="nav" aria-expanded="false" aria-controls="links">Navigation</button>
<nav id="links" aria-label="Account" hidden><a href="#profile">Profile</a><a href="#help">Help</a></nav>
<p role="status" id="status"></p></main>
<script>
const dialog = document.querySelector('dialog');
const opener = document.querySelector('#open');
const email = document.querySelector('#email');
const error = document.querySelector('#error');
const nav = document.querySelector('#nav');
const links = document.querySelector('#links');
opener.onclick = () => dialog.showModal();
document.querySelector('#close').onclick = () => dialog.close();
dialog.addEventListener('close', () => opener.focus());
dialog.addEventListener('keydown', event => {
const controls = [...dialog.querySelectorAll('input, button')];
const first = controls[0], last = controls.at(-1);
if (event.key === 'Tab' && event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
if (event.key === 'Tab' && !event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
});
document.querySelector('form').onsubmit = event => {
event.preventDefault();
const invalid = !email.value || !email.validity.valid;
error.hidden = !invalid;
if (invalid) { email.setAttribute('aria-invalid','true'); email.setAttribute('aria-describedby','error'); email.focus(); }
else { email.removeAttribute('aria-invalid'); email.removeAttribute('aria-describedby'); dialog.close(); document.querySelector('#status').textContent = 'Email saved.'; }
};
nav.onclick = () => { const open = nav.getAttribute('aria-expanded') === 'true'; nav.setAttribute('aria-expanded', String(!open)); links.hidden = open; };
</script></html>2. Enregistrer playwright.config.mjs
import { defineConfig } from '@playwright/test';
export default defineConfig({ testMatch: /states.spec.mjs/, workers: 1, retries: 0, use: { channel: 'chrome' }, reporter: [['list'], ['json', { outputFile: 'test-results.json' }]] });3. Enregistrer states.spec.mjs
Les localisateurs par rôle et par libellé expriment les noms accessibles attendus. Ce sont des contrats utiles, mais localiser un contrôle par son rôle ne prouve pas que l’ensemble de son interaction est accessible.6
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { readFileSync } from 'node:fs';
test.beforeEach(async ({ page }) => {
await page.setContent(readFileSync('fixture.html', 'utf8'));
});
async function scan(page, testInfo, state) {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
await testInfo.attach(`axe-${state}`, {
body: JSON.stringify(results, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
// This small fixture has a strict review gate; real apps need triage.
expect(results.incomplete).toEqual([]);
}
test('dialog opens, contains focus and returns it', async ({ page }, testInfo) => {
const opener = page.getByRole('button', { name: 'Edit email', exact: true });
await opener.focus();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: 'Edit email', exact: true });
await expect(dialog).toBeVisible();
await expect(dialog).toHaveAttribute('aria-modal', 'true');
const email = dialog.getByLabel('Email', { exact: true });
const save = dialog.getByRole('button', { name: 'Save', exact: true });
const cancel = dialog.getByRole('button', { name: 'Cancel', exact: true });
await expect(email).toBeFocused();
await page.keyboard.press('Tab');
await expect(save).toBeFocused();
await page.keyboard.press('Tab');
await expect(cancel).toBeFocused();
await page.keyboard.press('Tab');
await expect(email).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(cancel).toBeFocused();
await scan(page, testInfo, 'dialog');
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(opener).toBeFocused();
await page.keyboard.press('Enter');
await expect(email).toBeFocused();
await page.keyboard.press('Shift+Tab');
await page.keyboard.press('Enter');
await expect(dialog).toBeHidden();
await expect(opener).toBeFocused();
});
test('invalid email is associated and can be corrected', async ({ page }, testInfo) => {
await page.getByRole('button', { name: 'Edit email', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Edit email', exact: true });
await expect(dialog).toBeVisible();
const email = dialog.getByLabel('Email', { exact: true });
await email.fill('wrong');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('#error')).toBeVisible();
await expect(email).toHaveAttribute('aria-invalid', 'true');
await expect(email).toHaveAccessibleDescription('Enter a valid email address.');
await expect(email).toBeFocused();
await scan(page, testInfo, 'invalid');
await email.fill('reader@example.com');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole('status')).toHaveText('Email saved.');
await expect(page.locator('#error')).toBeHidden();
await expect(page.locator('#email')).not.toHaveAttribute('aria-invalid', 'true');
await scan(page, testInfo, 'saved');
});
test('navigation disclosure exposes ordinary links', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 375, height: 667 });
const trigger = page.getByRole('button', { name: 'Navigation', exact: true });
const links = page.getByRole('navigation', { name: 'Account', exact: true });
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(links).toBeHidden();
await trigger.focus();
await page.keyboard.press('Space');
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
await expect(links).toBeVisible();
await page.keyboard.press('Tab');
await expect(links.getByRole('link', { name: 'Profile', exact: true })).toBeFocused();
await scan(page, testInfo, 'navigation');
await page.keyboard.press('Shift+Tab');
await page.keyboard.press('Enter');
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(links).toBeHidden();
await expect(trigger).toBeFocused();
await expect(page.getByRole('link', { name: 'Profile', exact: true })).toHaveCount(0);
await scan(page, testInfo, 'navigation-collapsed');
});Ce que les assertions établissent
Le premier test active la boîte de dialogue au clavier, attend son nom accessible et son état visible, vérifie le focus à l’ouverture, puis contrôle les deux extrémités de sa séquence de tabulation. La touche Échap et le bouton visible « Cancel » la ferment et rendent le focus à l’élément déclencheur. Le motif de boîte de dialogue de l’APG décrit ces comportements, mais le focus initial n’a pas toujours à se placer sur le premier champ : un contenu long peut justifier de le placer sur un titre, et une confirmation destructive, sur l’action la plus sûre. Le focus peut aussi revenir vers une étape suivante logique lorsque l’élément déclencheur disparaît ou que le parcours l’exige.3
La méthode native showModal() utilisée par la page de test rend l’arrière-plan inerte. Une boîte de dialogue personnalisée nécessite une vérification distincte garantissant que les contrôles de l’arrière-plan ne peuvent pas être actionnés ; définir aria-modal="true" ne suffit pas à implémenter ce comportement.3 Examinez aussi le focus visible et l’ordre de lecture. Les assertions de focus programmatiques ne peuvent pas vous indiquer si un indicateur de focus est masqué par un en-tête fixe.
Le test de validation attend l’erreur visible, aria-invalid, la description accessible du champ et le focus. Il fournit ensuite une saisie valide et vérifie la fermeture, l’effacement de l’état d’erreur et le message de réussite. Vérifier uniquement aria-describedby serait moins robuste : l’élément référencé pourrait être absent ou contenir un texte erroné. L’assertion finale sur l’attribut utilise un localisateur DOM, car la boîte de dialogue fermée n’est plus exposée au localisateur par rôle par défaut.
Une assertion sur le texte d’une région d’état établit la mise à jour du DOM, et non ce qu’un lecteur d’écran a annoncé ni à quel moment. Testez les annonces avec les combinaisons de navigateurs et de technologies d’assistance que vous prenez en charge. Dans une application, attendez aussi la réponse réelle à l’enregistrement des données et vérifiez la valeur persistée ; cette page de test ne fait volontairement ni l’un ni l’autre.
Une navigation dépliable n’est pas un menu ARIA
Le troisième test utilise un bouton qui déplie des liens ordinaires. Il vérifie aria-expanded, la visibilité, l’activation avec la touche Espace, le passage au premier lien avec Tab et le repli avec Entrée. Une navigation de site web classique n’a besoin ni du rôle ARIA menu ni de son modèle clavier spécialisé.7
Pour un véritable menu de commandes, appliquez plutôt le contrat du bouton de menu : un bouton doté de aria-haspopup="menu", un aria-expanded synchronisé, un menu nommé et des éléments de menu correctement nommés. Les touches Entrée et Espace doivent l’ouvrir et placer le focus sur le premier élément.4 Ajoutez des tests pour la navigation aux touches fléchées prise en charge par le menu, l’activation, ainsi que la fermeture par Échap et le retour du focus. Ne reprenez pas telles quelles des assertions de navigation dépliable pour un widget de menu en considérant que la couverture est complète.
Interpréter le résultat d’axe, y compris les résultats incomplets
La fonction utilitaire sélectionne les tags WCAG A/AA, jusqu’à la version 2.2, disponibles dans cette version d’axe. Cela sélectionne les règles automatisées portant ces tags, et non chaque critère de succès de ces niveaux. Les analyses axe par défaut incluent aussi des règles classées comme bonnes pratiques ; filtrer par tags WCAG modifie ce périmètre.15
Le résultat contient violations, passes, incomplete et inapplicable. Un résultat incomplet doit être examiné : axe n’a pas pu déterminer si le nœud concerné réussissait ou échouait. Une règle inapplicable n’a trouvé aucun contenu pertinent ; ce n’est pas la preuve que vous avez testé la fonctionnalité correspondante.5
Cette petite page de test échoue en cas de violations comme de constats incomplets, après avoir joint le résultat complet. Pour une application réelle, vous pouvez plutôt orienter les constats incomplets vers une file d’examen dotée d’un responsable. Faites-en une règle explicite assortie d’une échéance d’examen, plutôt que d’ignorer silencieusement ce tableau ou de le traiter comme une réussite. Enregistrez avec les artefacts la version du moteur issue de results.testEngine, le navigateur ou projet, le nom de l’état et le commit. Redéfinissez délibérément la référence après chaque mise à niveau des dépendances.
Définir précisément le périmètre d’analyse
Ces tests analysent toute la page courante. .include('#panel') peut réduire le bruit lors de la vérification d’un composant, mais n’établit aucune couverture en dehors de ce sous-arbre. .exclude() retire l’élément sélectionné et ses descendants de toutes les règles ; une exclusion large peut donc masquer des régressions sans rapport.1
L’adaptateur axe pour Playwright injecte automatiquement le moteur dans les cadres (frames). Son mode hérité désactive le test des cadres d’origine différente et signale les cadres non testés ; une erreur d’analyse ou un cadre non testé constitue une lacune de couverture, et non un résultat sans défaut.8 Attendez le chargement du contenu intégré, examinez les résultats liés aux cadres et testez explicitement les parcours intégrés importants.
Les localisateurs Playwright prennent généralement en charge les shadow roots ouvertes, sauf avec XPath ; les shadow roots fermées ne sont pas prises en charge. Axe peut parcourir le shadow DOM ouvert, mais le fait qu’un localisateur fonctionne n’établit pas en soi la couverture de l’analyse.56 Documentez séparément les shadow roots fermées et les widgets tiers. La page de test ci-dessus ne contient ni iframe ni shadow root : son résultat positif ne permet donc de rien affirmer sur l’un ou l’autre.
Exploiter des échecs utiles en CI
Utilisez des utilisateurs synthétiques pour les pages de test, des comptes dédiés pour les tests authentifiés, et restreignez l’accès aux artefacts. Les résultats d’axe contiennent des extraits du DOM ; les traces, captures d’écran et journaux peuvent exposer des données personnelles et des informations de session. Fixez des durées de conservation et supprimez les données sensibles avant tout partage.
Exécutez les tests des états critiques sur les pull requests. Ajoutez des fenêtres d’affichage mobiles représentatives, les autres navigateurs pris en charge et des vérifications manuelles, selon une fréquence adaptée au produit. Le résultat d’une page de test limitée à Chrome ne doit jamais être présenté comme une couverture multinavigateur. En cas d’échec, conservez l’état, l’identifiant de la règle, les cibles concernées, les étapes de reproduction et le responsable désigné. Préférez une exception ciblée et limitée dans le temps à la désactivation d’une règle dans toute l’application.
Pour les parcours de compte, étendez cette approche aux écrans de secours et de récupération, comme décrit dans le guide de déploiement des clés d’accès. Partez d’une tâche utilisateur qui échoue, rédigez son contrat observable, puis corrigez le composant et relancez le test de cet état. Vous obtenez ainsi un test de non-régression que d’autres personnes pourront maintenir.
Points clés
- 1Atteignez chaque état rendu et vérifiez-le avant d’exécuter axe ; la fin d’une action n’indique pas que l’interface est prête.
- 2Vérifiez le nom des boîtes de dialogue, le confinement du focus, leur fermeture et la destination appropriée du focus.
- 3Vérifiez ensemble les erreurs visibles, l’association des messages aux champs et la récupération réussie.
- 4Traitez les constats incomplets comme du travail d’examen, et consignez les versions et les périmètres d’analyse.
- 5Utilisez STATE pour sélectionner et déclencher les états, vérifier les résultats, trier les constats et établir les preuves.
Conclusion
Choisissez un état critique pour l’utilisateur et définissez ses résultats attendus sur les plans visuel, sémantique et clavier avant de lancer l’analyse. Conservez ensemble le test, le périmètre d’analyse et les preuves d’examen afin qu’un échec futur soit reproductible.
Questions fréquentes
Une analyse axe réussie prouve-t-elle la conformité WCAG ?
Non. Elle rend compte de vérifications automatisées sélectionnées pour l’état rendu. L’évaluation manuelle, les tests avec des technologies d’assistance et les tests utilisateurs inclusifs restent nécessaires.
Faut-il analyser la page entière ou un seul composant ?
Analysez la page entière pour l’état concerné lorsque c’est possible. Une analyse ciblée est utile pour un composant, mais consignez son périmètre et maintenez des vérifications distinctes pour le contenu exclu.
Que faire des résultats incomplets ?
Examinez les nœuds concernés : axe n’a pas pu déterminer s’ils réussissaient ou échouaient. Faites échouer le test dans l’attente d’un examen, ou suivez les constats dans une file dotée d’un responsable et d’une échéance.
La méthode fill attend-elle qu’un élément soit immobile ?
Non. Pour fill, Playwright vérifie que l’élément est visible, activé et modifiable. Vérifiez séparément l’état obtenu dans l’application avant de lancer l’analyse.
Puis-je réutiliser ces tests en production ?
Remplacez la page de test synthétique par votre application et mettez à jour les noms accessibles, les données et les assertions qui vérifient que l’interface est prête. Ajoutez les navigateurs et fenêtres d’affichage pris en charge ainsi que des vérifications manuelles ; l’exemple ne teste ni la persistance ni un service réel.
Sources
- https://playwright.dev/docs/accessibility-testing
- https://playwright.dev/docs/actionability
- https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal
- https://www.w3.org/WAI/ARIA/apg/patterns/menu-button
- https://www.deque.com/axe/core-documentation/api-documentation
- https://playwright.dev/docs/locators
- https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation
- https://raw.githubusercontent.com/dequelabs/axe-core-npm/0bcb1850683addb69343067263aa3fcd4ddd0561/packages/playwright/README.md
Rédigé par
Hamza DiazHamza Diaz est le fondateur d’Optijara, où il conçoit des agents IA pratiques, des systèmes d’automatisation et des workflows Copilot pour les entreprises de services. Il écrit sur les opérations IA, la stratégie d’agents et la mise en œuvre concrète pour les équipes qui veulent des systèmes utiles plutôt que du battage médiatique.
