Gérer le Shadow DOM dans les tests automatisés de navigateur
Le Shadow DOM offre une véritable encapsulation pour les composants web. Les styles extérieurs n’y pénètrent pas, les sélecteurs ne débordent pas vers l’extérieur et les détails internes des composants restent privés. Les grands systèmes de design — le framework Katal d’Amazon, les Salesforce Lightning Web Components, les Material Web Components — utilisent largement le Shadow DOM précisément parce que cette encapsulation rend les composants prévisibles et portables.
Le problème est que la même encapsulation qui rend le Shadow DOM utile en production pose de grandes difficultés lors des tests automatisés. La plupart des frameworks d’automatisation, y compris Selenium, Cypress, et dans une certaine mesure Playwright, ont été construits autour de document.querySelector, et le Shadow DOM est spécifiquement conçu pour être invisible à document.querySelector.
Pourquoi le Shadow DOM fait échouer l’automatisation
Le problème principal est la frontière du Shadow DOM. Quand un composant effectue son rendu dans une racine du Shadow DOM, son sous-arbre DOM existe dans un fragment de document séparé que les requêtes DOM normales ne peuvent pas traverser. Un appel comme document.querySelector('.checkout-button') ne trouvera pas un élément avec cette classe s’il réside dans une racine du Shadow DOM, même s’il est visuellement présent sur la page et entièrement interactif pour un utilisateur humain.
Ce n’est pas un bug du navigateur. C’est la spécification qui fonctionne comme prévu. L’encapsulation du Shadow DOM empêche les sélecteurs CSS et les requêtes DOM d’atteindre l’intérieur des composants depuis l’extérieur ; le JavaScript externe ne peut y accéder que par une référence explicite, comme la propriété shadowRoot d’une racine ouverte. Les méthodes querySelector disponibles sur document ou sur les éléments DOM normaux s’arrêtent aux frontières du Shadow DOM. Considérez cette structure :
<!-- Light DOM -->
<katal-button>
#shadow-root
<button class="kds-button">Add to cart</button>
</katal-button>
Un sélecteur comme document.querySelector('katal-button .kds-button') retourne null. L’élément existe dans le DOM, mais la requête ne peut pas l’atteindre. Les frameworks d’automatisation qui s’appuient sur une seule chaîne de sélection depuis la racine du document échouent silencieusement ici — l’élément n’est pas manquant, il est encapsulé.
Racines du Shadow DOM ouvertes et fermées
Les racines du Shadow DOM existent en deux modes : open et closed. Cette distinction détermine en grande partie les approches d’automatisation disponibles.
Une racine du Shadow DOM ouverte expose ses éléments internes via la propriété shadowRoot sur l’élément hôte :
const host = document.querySelector('katal-button');
const button = host.shadowRoot.querySelector('.kds-button');
Une racine du Shadow DOM fermée retourne null pour element.shadowRoot. Certains systèmes de composants d’entreprise utilisent des racines du Shadow DOM fermées en production pour empêcher le code externe de dépendre des détails d’implémentation internes. Les racines fermées existent spécifiquement pour bloquer le schéma ci-dessus.
En pratique, la plupart des ingénieurs d’automatisation rencontrent plus fréquemment des racines du Shadow DOM ouvertes. Les éléments natifs du navigateur comme <input type="date"> et <video> affichent également leurs contrôles dans des arbres du Shadow DOM, mais ces racines de l’agent utilisateur ne sont exposées ni aux scripts de la page ni aux localisateurs de Playwright. Les racines fermées apparaissent principalement dans les systèmes de design d’entreprise et nécessitent une approche fondamentalement différente, que nous aborderons séparément.
Traversée manuelle avec shadowRoot.querySelector
Pour les racines du Shadow DOM ouvertes, l’approche simple est la traversée explicite. Au lieu d’un sélecteur depuis la racine du document, vous naviguez à travers les frontières du Shadow DOM par étapes. Dans Playwright, la méthode evaluate exécute du JavaScript dans le contexte de la page et peut traverser directement les racines du Shadow DOM :
// Ne fonctionne pas — evaluate ne peut pas retourner des éléments DOM par valeur
const button = await page.evaluate(() => {
const host = document.querySelector('katal-button');
return host?.shadowRoot?.querySelector('.kds-button');
});
// Fonctionne — evaluateHandle retourne un handle vers l'élément live
const buttonHandle = await page.evaluateHandle(() => {
const host = document.querySelector('katal-button');
return host?.shadowRoot?.querySelector('.kds-button');
});
await buttonHandle.asElement()?.click();
La distinction entre evaluate et evaluateHandle est importante ici. evaluate sérialise la valeur de retour à travers la frontière navigateur-Node.js — les nœuds DOM ne sont pas sérialisables, donc vous obtenez undefined plutôt que l’élément. evaluateHandle retourne un objet handle qui maintient la référence active dans le processus du navigateur, permettant des appels d’interaction ultérieurs.
Mais en réalité, ce schéma est fragile. Une mise à jour de composant qui effectue un nouveau rendu du nœud interne ou le remplace laisse le handle pointer vers un élément détaché du DOM. Pour les imbrications profondes — une racine du Shadow DOM dans une racine du Shadow DOM dans une autre — le code de traversée devient verbeux et fragile. Il y a une meilleure approche.
Le sélecteur pierce
Les localisateurs CSS de Playwright traversent les racines ouvertes du Shadow DOM par défaut : page.locator('katal-button .kds-button') atteint donc déjà le bouton. Le jeton >> chaîne les sélecteurs en évaluant chaque partie relativement à la correspondance précédente, ce qui explicite la structure allant de l’hôte à ses éléments internes :
// Traverser la racine d'ombre de katal-button
const button = page.locator('katal-button >> .kds-button');
await button.click();
Avec >>, le côté gauche localise l’hôte du Shadow DOM et le côté droit effectue la recherche relativement à celui-ci ; comme le moteur CSS de Playwright traverse les racines ouvertes, cela inclut la racine de l’hôte et toutes les racines ouvertes qui y sont imbriquées. Pour des imbrications plus profondes, le chaînage fonctionne :
// Trois niveaux de profondeur
const input = page.locator('my-form >> my-field >> input[type="text"]');
Cette approche est nettement plus claire que le parcours manuel et résiste mieux aux mises à jour structurelles des composants que les schémas utilisant evaluateHandle. La logique de nouvelle tentative intégrée à Playwright permet au localisateur de rechercher à nouveau l’élément jusqu’à ce que l’élément apparaisse, ce qui gère automatiquement le rendu asynchrone.
Les localisateurs sémantiques de Playwright — getByRole, getByLabel, getByText — traversent également les racines ouvertes du Shadow DOM ; dans Playwright, tous les types de localisateurs le font, sauf XPath. Pour les composants conformes à ARIA, c’est l’option la plus maintenable, car elle teste le comportement plutôt que la structure :
// Fonctionne si le bouton Shadow DOM a un rôle accessible et du texte
await page.getByRole('button', { name: 'Add to cart' }).click();
La limitation des sélecteurs pierce est qu’ils ne fonctionnent qu’avec des racines du Shadow DOM ouvertes. Les racines du Shadow DOM fermées ne sont pas prises en charge par les localisateurs de Playwright.
Attendre la création et le remplissage des racines du Shadow DOM
Le Shadow DOM introduit un problème de synchronisation que les tests sur le DOM léger (light DOM) font rarement apparaître. Quand un élément personnalisé se met à niveau et attache une racine du Shadow DOM, il y a deux événements asynchrones distincts : l’apparition de l’élément dans le DOM, et le remplissage de la racine du Shadow DOM avec du contenu. Attendre l’élément hôte ne garantit pas que le contenu du Shadow DOM est prêt.
// Non fiable — attend l'hôte, pas le contenu d'ombre
await page.waitForSelector('katal-button');
// Cela peut encore échouer immédiatement après
await page.locator('katal-button >> .kds-button').click();
La bonne approche attend directement le contenu du Shadow DOM. Comme les localisateurs Playwright réessaient automatiquement, locator.click() gère déjà ce cas dans la plupart des situations — mais pour les composants pilotés par les données qui récupèrent la configuration avant le rendu, le délai prévu pour les nouvelles tentatives peut expirer avant que le contenu n’apparaisse. Une attente explicite avec un sélecteur significatif est plus robuste :
// Attendre qu'un élément Shadow DOM spécifique devienne visible
await page.locator('katal-button >> .kds-button').waitFor({ state: 'visible' });
await page.locator('katal-button >> .kds-button').click();
// Pour les composants chargés de manière asynchrone, attendre un indicateur de données prêtes
await page.locator('katal-product-card >> [data-loaded="true"]').waitFor();
L’appel waitFor explicite produit également des messages d’échec plus informatifs. L’expiration du délai de waitForSelector('katal-button') indique que l’hôte n’a pas été trouvé ; l’expiration du délai de waitFor({ state: 'visible' }) sur le localisateur pierce indique que c’est précisément le contenu du Shadow DOM qui n’était pas prêt. Cette distinction compte pour déboguer les tests intermittents.
Propagation d’événements entre racines du Shadow DOM
La gestion des événements à travers les frontières du Shadow DOM a des subtilités qui affectent le comportement des tests d’interaction. Les événements d’interface utilisateur déclenchés par le navigateur, comme les événements de clic, de saisie et de clavier, sont composés (composed: true) : ils se propagent à travers les frontières du Shadow DOM et remontent dans le document externe. La plupart des autres événements, notamment les événements synthétiques créés sans composed: true, ne le sont pas.
Mais la propriété target est redirigée. Un écouteur d’événement sur l’élément hôte du Shadow DOM voit l’hôte lui-même comme cible, pas l’élément interne du Shadow DOM où l’événement a été déclenché. Cela importe si vos tests vérifient les cibles d’événements, ou si le code applicatif utilise event.target pour identifier avec quel élément l’utilisateur a interagi :
host.addEventListener('click', (e) => {
console.log(e.target); // journalise l'élément hôte, pas le bouton interne
console.log(e.composedPath()); // journalise le chemin complet incluant les composants internes d'ombre
});
Pour les racines ouvertes du Shadow DOM, event.composedPath() est la façon fiable d’inspecter ce qui s’est réellement passé lors d’une interaction ; pour les racines fermées, les écouteurs situés à l’extérieur de l’hôte voient le chemin s’arrêter à l’hôte. Pour les tests qui vérifient le comportement de déclenchement des événements, vérifier composedPath() plutôt que target donne une image fidèle.
Les événements personnalisés sont plus problématiques. Ils ont par défaut composed: false, ce qui signifie qu’ils ne traversent pas du tout les frontières du Shadow DOM. Un composant qui déclenche un événement personnalisé interne sans définir explicitement composed: true ne sera pas observable depuis l’extérieur de l’hôte du Shadow DOM :
// À l'intérieur du composant — cet événement NE ATTEINDRA PAS les écouteurs extérieurs
this.shadowRoot.dispatchEvent(new CustomEvent('katal-select', {
bubbles: true,
composed: false, // par défaut — l'événement reste à l'intérieur de la racine d'ombre
detail: { value: selectedItem },
}));
// À l'extérieur du composant — cet écouteur ne se déclenche jamais
host.addEventListener('katal-select', handler); // jamais appelé
Quand un composant ne fait pas remonter les événements au DOM léger, tester le déclenchement des événements depuis l’extérieur est véritablement impossible par des moyens standard. Les options pratiques sont : utiliser page.evaluate pour ajouter un observateur à l’intérieur de la racine du Shadow DOM avant l’action, ou tester le changement d’état observable qui devrait résulter de l’événement plutôt que l’événement lui-même. Cette dernière approche est généralement plus significative de toute façon — tester qu’une sélection est reflétée dans les attributs publics du composant est plus résilient que tester qu’un événement s’est déclenché.
Ce que les racines du Shadow DOM fermées nécessitent
Pour les racines du Shadow DOM fermées, aucune des approches basées sur des sélecteurs ne fonctionne. La propriété shadowRoot retourne null, les sélecteurs pierce ne peuvent pas traverser une frontière fermée, et la traversée evaluateHandle échoue au même point. Les approches à envisager sont fondamentalement différentes.
Utiliser l’API publique. Les composants web bien conçus exposent des attributs, des propriétés ou des slots qui contrôlent leur état sans nécessiter d’accès interne. Si un composant prend en charge aria-checked, cliquer dessus et vérifier l’attribut aria est à la fois adapté à l’automatisation et sémantiquement correct. Tester ce que le composant expose est plus maintenable que tester ce qu’il cache.
Tester au niveau du composant. Les composants Shadow DOM sont souvent testables isolément à l’aide d’outils comme @web/test-runner ou les utilitaires de test de Lit, qui vous donnent un environnement maîtrisé pour tester directement le composant via sa propre API. Ils n’ouvrent pas eux-mêmes une racine fermée, mais facilitent le test isolé de l’API publique. Les tests de bout en bout au niveau de la page doivent vérifier le comportement d’intégration ; les tests au niveau composant peuvent vérifier le bon fonctionnement interne.
Négocier avec l’auteur du composant. Les racines du Shadow DOM fermées sont un choix délibéré. Si une bibliothèque de composants bloque l’automatisation de tests légitime sans fournir d’interfaces publiques testables, cela constitue un problème de qualité qu’il est légitime de signaler. Le mode ouvert du Shadow DOM, accompagné d’une documentation claire sur les éléments internes qui restent stables, est un compromis raisonnable.
Centraliser les localisateurs du Shadow DOM
Les schémas fiables dans les suites de tests en production privilégient des définitions explicites et centralisées des localisateurs, plutôt qu’un code de parcours astucieux écrit directement dans chaque test. Un module utilitaire qui encapsule les chemins pierce courants garde les tests lisibles et isole l’impact des mises à jour de composants :
// helpers/katal.ts
import type { Page } from '@playwright/test';
export const katalButton = (page: Page, text: string) =>
page.locator('katal-button >> button').filter({ hasText: text });
export const katalSelect = (page: Page, label: string) =>
page.locator(`katal-select[label="${label}"] >> select`);
export const katalInput = (page: Page, name: string) =>
page.locator(`katal-input[name="${name}"] >> input`);
// Dans les tests
await katalButton(page, 'Add to cart').click();
await katalInput(page, 'email').fill('[email protected]');
Quand Katal met à jour sa structure interne, vous mettez à jour une fonction plutôt que de fouiller dans chaque fichier de test. C’est le même principe qui rend les Page Object Models utiles, appliqué spécifiquement à la couche d’encapsulation que le Shadow DOM introduit. La frontière du composant devient la frontière d’abstraction pour vos utilitaires de test.
Le problème sous-jacent
Les difficultés rencontrées pour automatiser les tests du Shadow DOM montrent que l’outillage de test ne s’est pas encore complètement adapté au modèle de composants. Les frameworks construits autour d’un accès global querySelector supposent un DOM plat et accessible — le contraire de ce que le Shadow DOM est conçu pour fournir.
Mais en réalité, l’encapsulation est correcte. Les composants qui n’exposent que leur interface publique et cachent les détails d’implémentation sont plus faciles à maintenir, mettre à niveau et composer. La friction dans les tests reflète un écart dans la façon dont l’outillage de test conceptualise l’accès au DOM, pas un défaut dans le Shadow DOM lui-même. Les localisateurs de Playwright qui traversent le Shadow DOM et ses localisateurs sémantiques représentent des progrès vers un modèle de test qui fonctionne avec l’encapsulation plutôt que contre elle.
La conclusion pratique : utiliser les sélecteurs pierce pour les racines du Shadow DOM ouvertes, tester l’état observable plutôt que la structure interne pour les racines fermées, et centraliser les localisateurs de parcours du Shadow DOM pour que les mises à jour de composants nécessitent des changements en un seul endroit plutôt que dans toute une suite de tests. Le Shadow DOM est là pour rester — les approches d’automatisation doivent être construites autour de cette réalité.
Analyses connexes
- Automatisation des navigateurs à grande échelle sans blocage — exécuter Playwright de manière fiable sur des sites en production