tva
← Insights

Umgang mit Shadow DOM in automatisierten Browser-Tests

Shadow DOM bietet echte Kapselung für Web Components. Styles dringen nicht hinein, Selektoren reichen nicht heraus und interne Komponentendetails bleiben verborgen. Große Designsysteme – Amazons Katal-Framework, Salesforce Lightning Web Components und Material Web Components – setzen Shadow DOM gerade deshalb umfassend ein: Diese Kapselung macht Komponenten vorhersehbar und portabel.

Das Problem: Dieselbe Kapselung, die Shadow DOM im Produktivbetrieb nützlich macht, erschwert automatisierte Tests erheblich. Die meisten Automatisierungsframeworks, darunter Selenium, Cypress und teilweise Playwright, wurden auf der Grundlage von document.querySelector entwickelt. Shadow DOM ist jedoch ausdrücklich so konzipiert, dass es für document.querySelector unsichtbar bleibt.

Warum Shadow DOM die Automatisierung erschwert

Das Kernproblem ist die Shadow-Grenze. Rendert eine Komponente in einem Shadow Root, liegt ihr DOM-Teilbaum in einem separaten Dokumentfragment, das normale DOM-Abfragen nicht durchlaufen können. Ein Aufruf wie document.querySelector(‘.checkout-button’) findet ein Element mit dieser Klasse nicht, wenn es innerhalb eines Shadow Roots liegt – selbst wenn es sichtbar auf der Seite steht und für Menschen vollständig bedienbar ist.

Das ist kein Browserfehler, sondern das vorgesehene Verhalten der Spezifikation. Die Kapselung durch Shadow DOM verhindert, dass CSS-Selektoren und DOM-Abfragen von außen auf das Innere von Komponenten zugreifen; externes JavaScript erhält nur über eine explizite Referenz Zugriff, etwa über die Eigenschaft shadowRoot eines offenen Roots. Die querySelector-Methoden von document oder normalen DOM-Elementen halten an Shadow-Grenzen an. Betrachten Sie diese Struktur:

<!-- Light DOM -->
<katal-button>
  #shadow-root
    <button class="kds-button">Add to cart</button>
</katal-button>

Ein Selektor wie document.querySelector(‘katal-button .kds-button’) liefert null zurück. Das Element existiert im DOM, ist für die Abfrage aber nicht erreichbar. Automatisierungsframeworks, die sich auf einen einzelnen Selektor ausgehend von der Dokumentwurzel verlassen, scheitern hier ohne deutlichen Hinweis: Das Element fehlt nicht, es ist gekapselt.

Offene und geschlossene Shadow Roots

Shadow Roots haben zwei Modi: open und closed. Dieser Unterschied ist entscheidend dafür, welche Automatisierungsansätze verfügbar sind.

Ein offener Shadow Root macht seine internen Elemente über die Eigenschaft shadowRoot des Host-Elements zugänglich:

const host = document.querySelector('katal-button');
const button = host.shadowRoot.querySelector('.kds-button');

Bei einem geschlossenen Shadow Root liefert element.shadowRoot null zurück. Einige Komponentensysteme für Unternehmen verwenden im Produktivbetrieb geschlossene Shadow Roots, damit externer Code nicht von internen Implementierungsdetails abhängt. Geschlossene Roots sollen ausdrücklich das oben gezeigte Muster verhindern.

In der Praxis begegnen Automatisierungsentwicklern häufiger offene Shadow Roots. Browsereigene Elemente wie <input type="date"> und <video> rendern ihre Bedienelemente ebenfalls in Shadow-Bäumen, doch diese User-Agent-Roots sind weder für Seitenskripte noch für Playwright-Locators zugänglich. Geschlossene Roots treten vor allem in Designsystemen für Unternehmen auf und verlangen einen grundlegend anderen Ansatz, auf den wir gesondert eingehen.

Manuelles Durchlaufen mit shadowRoot.querySelector

Bei offenen Shadow Roots ist das explizite Durchlaufen der naheliegende Ansatz. Statt einen einzigen Selektor von der Dokumentwurzel aus zu verwenden, navigieren Sie schrittweise durch die Shadow-Grenzen. In Playwright führt die Methode evaluate JavaScript im Seitenkontext aus und kann Shadow Roots direkt durchlaufen:

// Doesn't work — evaluate can't return DOM elements by value
const button = await page.evaluate(() => {
  const host = document.querySelector('katal-button');
  return host?.shadowRoot?.querySelector('.kds-button');
});

// Works — evaluateHandle returns a handle to the live element
const buttonHandle = await page.evaluateHandle(() => {
  const host = document.querySelector('katal-button');
  return host?.shadowRoot?.querySelector('.kds-button');
});

await buttonHandle.asElement()?.click();

Der Unterschied zwischen evaluate und evaluateHandle ist hier wesentlich. evaluate serialisiert den Rückgabewert für die Übertragung zwischen Browser und Node.js. DOM-Knoten lassen sich nicht serialisieren, weshalb Sie undefined statt des Elements erhalten. evaluateHandle gibt ein Handle-Objekt zurück, das die Referenz im Browserprozess aufrechterhält und nachfolgende Interaktionen ermöglicht.

In der Praxis ist dieses Muster jedoch empfindlich. Eine Komponentenaktualisierung, die den internen Knoten neu rendert oder ersetzt, lässt das Handle auf ein vom DOM getrenntes Element zeigen. Bei tiefer Verschachtelung – einem Shadow Root in einem weiteren und dieser wiederum in einem dritten – wird der Traversierungscode lang und störanfällig. Es gibt einen besseren Ansatz.

Der Pierce-Selektor

Playwrights CSS-Locators durchdringen offene Shadow Roots standardmäßig, sodass page.locator('katal-button .kds-button') den Button bereits erreicht. Das Token >> verkettet Selektoren und fragt jeden Teil relativ zum vorherigen Treffer ab. So wird die Abfolge vom Host zu dessen Interna ausdrücklich sichtbar:

// Pierce through the shadow root of katal-button
const button = page.locator('katal-button >> .kds-button');
await button.click();

Bei >> findet die linke Seite den Shadow Host, und die rechte fragt relativ dazu ab; weil Playwrights CSS-Engine offene Shadow Roots durchdringt, schließt das den Shadow Root des Hosts und darin verschachtelte offene Roots ein. Bei tieferer Verschachtelung lässt sich die Kette erweitern:

// Three levels deep
const input = page.locator('my-form >> my-field >> input[type="text"]');

Das ist wesentlich übersichtlicher als manuelles Durchlaufen und übersteht Strukturänderungen an Komponenten besser als Muster mit evaluateHandle. Playwrights eingebaute Wiederholungslogik lässt den Locator die Suche wiederholen, bis das Element erscheint, und behandelt damit asynchrones Rendering automatisch.

Auch Playwrights semantische Locators – getByRole, getByLabel und getByText – durchdringen ebenfalls offene Shadow Roots; in Playwright gilt das für jeden Locator-Typ außer XPath. Für ARIA-konforme Komponenten ist das die am besten wartbare Option, weil sie Verhalten statt Struktur prüft:

// Works if the shadow DOM button has accessible role and text
await page.getByRole('button', { name: 'Add to cart' }).click();

Pierce-Selektoren funktionieren allerdings nur bei offenen Shadow Roots. Geschlossene Shadow Roots werden von Playwrights Locators nicht unterstützt.

Warten, bis Shadow Roots bereit sind

Shadow DOM bringt ein Timingproblem mit sich, das bei Tests im Light DOM selten auftritt. Wenn ein Custom Element initialisiert wird und einen Shadow Root anhängt, gibt es zwei getrennte asynchrone Ereignisse: Das Element erscheint im DOM, und der Shadow Root wird mit Inhalt gefüllt. Auf das Host-Element zu warten garantiert nicht, dass der Shadow-Inhalt bereit ist.

// Unreliable — waits for the host, not the shadow content
await page.waitForSelector('katal-button');
// This may still fail immediately after
await page.locator('katal-button >> .kds-button').click();

Das richtige Muster wartet direkt auf den Shadow-Inhalt. Weil Playwright-Locators automatisch Wiederholungsversuche ausführen, behandelt locator.click() diesen Fall meist bereits. Bei datenabhängigen Komponenten, die vor dem Rendern ihre Konfiguration abrufen, kann das Zeitlimit aber ablaufen, bevor der Inhalt erscheint. Explizites Warten mit einem aussagekräftigen Selektor ist zuverlässiger:

// Wait for a specific shadow DOM element to become visible
await page.locator('katal-button >> .kds-button').waitFor({ state: 'visible' });
await page.locator('katal-button >> .kds-button').click();

// For async-loaded components, wait for a data-ready indicator
await page.locator('katal-product-card >> [data-loaded="true"]').waitFor();

Der explizite waitFor-Aufruf liefert zudem aussagekräftigere Fehlermeldungen. Ein Timeout bei waitForSelector(‘katal-button’) besagt, dass der Host nicht gefunden wurde. Ein Timeout bei waitFor({ state: ‘visible’ }) auf dem Pierce-Locator zeigt dagegen, dass konkret der Shadow-Inhalt nicht bereit war. Dieser Unterschied ist bei der Fehlersuche in instabilen Tests wichtig.

Ereignisweitergabe über Shadow-Grenzen hinweg

Die Ereignisbehandlung über Shadow-Grenzen hinweg hat Feinheiten, die das Verhalten von Interaktionstests beeinflussen. Vom Browser ausgelöste UI-Ereignisse wie Klick-, Eingabe- und Tastaturereignisse sind zusammengesetzt (composed: true): Sie überschreiten Shadow-Grenzen und steigen im äußeren Dokument auf. Die meisten anderen Ereignisse, einschließlich synthetischer Ereignisse, die ohne composed: true erzeugt werden, sind es nicht.

Die Eigenschaft target wird jedoch auf ein anderes Ziel umgelenkt. Ein Ereignislistener am Shadow Host sieht den Host selbst als Ziel, nicht das interne Shadow-DOM-Element, an dem das Ereignis entstand. Das ist relevant, wenn Ihre Tests Ereignisziele prüfen oder der Anwendungscode mit event.target bestimmt, mit welchem Element interagiert wurde:

host.addEventListener('click', (e) => {
  console.log(e.target);         // logs the host element, not the internal button
  console.log(e.composedPath()); // logs the full path including shadow internals
});

Bei offenen Shadow Roots lässt sich mit event.composedPath() zuverlässig untersuchen, was bei der Interaktion tatsächlich geschah; bei geschlossenen Roots sehen Listener außerhalb des Hosts nur einen Pfad bis zum Host. Bei Tests der Ereignisweitergabe liefert die Prüfung von composedPath() statt target das richtige Bild.

Benutzerdefinierte Ereignisse sind schwieriger. Standardmäßig gilt für sie composed: false, sie überschreiten also keine Shadow-Grenzen. Löst eine Komponente ein internes benutzerdefiniertes Ereignis aus, ohne ausdrücklich composed: true zu setzen, ist es von außerhalb des Shadow Hosts nicht beobachtbar:

// Inside the component — this event will NOT reach outside listeners
this.shadowRoot.dispatchEvent(new CustomEvent('katal-select', {
  bubbles: true,
  composed: false, // default — event stays inside shadow root
  detail: { value: selectedItem },
}));

// Outside the component — this listener never fires
host.addEventListener('katal-select', handler); // never called

Gibt eine Komponente Ereignisse nicht an das Light DOM weiter, lässt sich ihre Auslösung von außen mit Standardmitteln tatsächlich nicht testen. Praktische Möglichkeiten sind: vor der Aktion mit page.evaluate einen Beobachter innerhalb des Shadow Roots einrichten oder statt des Ereignisses die beobachtbare Zustandsänderung prüfen, die daraus folgen soll. Letzteres ist meist ohnehin aussagekräftiger: Zu testen, ob sich eine Auswahl in öffentlichen Attributen der Komponente widerspiegelt, ist stabiler als zu prüfen, ob ein Ereignis ausgelöst wurde.

Was geschlossene Shadow Roots erfordern

Bei geschlossenen Shadow Roots funktioniert keiner der selektorbasierten Ansätze. Die Eigenschaft shadowRoot liefert null, Pierce-Selektoren können die geschlossene Grenze nicht durchlaufen und die Traversierung mit evaluateHandle scheitert an derselben Stelle. Die möglichen Wege unterscheiden sich grundlegend.

Nutzen Sie die öffentliche API. Gut konzipierte Web Components stellen Attribute, Eigenschaften oder Slots bereit, über die sich ihr Zustand ohne internen Zugriff steuern lässt. Unterstützt eine Komponente aria-checked, ist es sowohl automatisierungsfreundlich als auch semantisch korrekt, sie anzuklicken und das ARIA-Attribut zu prüfen. Was eine Komponente offenlegt, lässt sich leichter wartbar testen als das, was sie verbirgt.

Testen Sie auf Komponentenebene. Shadow-DOM-Komponenten lassen sich häufig isoliert mit Werkzeugen wie @web/test-runner oder den Testwerkzeugen von Lit prüfen. Diese bieten eine kontrollierte Umgebung, um die Komponente direkt über ihre eigene API zu testen. Sie öffnen einen geschlossenen Root nicht von selbst, erleichtern aber das isolierte Testen der öffentlichen API. End-to-End-Tests auf Seitenebene sollten das Zusammenspiel prüfen; Komponententests können die interne Korrektheit prüfen.

Sprechen Sie mit den Komponentenentwicklern. Geschlossene Shadow Roots sind eine bewusste Entscheidung. Blockiert eine Komponentenbibliothek berechtigte Testautomatisierung, ohne testbare öffentliche Schnittstellen anzubieten, ist das ein berechtigtes Qualitätsproblem, das angesprochen werden sollte. Ein offener Shadow-Modus mit klarer Dokumentation der stabilen internen Elemente ist ein sinnvoller Mittelweg.

Shadow-DOM-Locators zentralisieren

Die Muster, die in produktiven Testsuiten zuverlässig funktionieren, vermeiden raffinierte Traversierung direkt im Test und setzen auf explizite, zentral definierte Locators. Ein Hilfsmodul, das häufige Pierce-Pfade kapselt, hält Tests lesbar und begrenzt die Auswirkungen von Komponentenaktualisierungen:

// 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`);

// In tests
await katalButton(page, 'Add to cart').click();
await katalInput(page, 'email').fill('[email protected]');

Ändert Katal seine interne Struktur, passen Sie eine Funktion an, statt jede Testdatei zu durchsuchen. Das ist dasselbe Prinzip, das Page Object Models nützlich macht, hier gezielt auf die Kapselungsebene von Shadow DOM angewendet. Die Komponentengrenze wird zur Abstraktionsgrenze Ihrer Testhelfer.

Das grundlegende Problem

Die Schwierigkeiten bei der Shadow-DOM-Automatisierung zeigen, dass Testwerkzeuge noch nicht vollständig mit dem Komponentenmodell Schritt halten. Frameworks mit globalem querySelector-Zugriff setzen ein flaches, zugängliches DOM voraus – das Gegenteil dessen, was Shadow DOM bieten soll.

Die Kapselung ist jedoch richtig. Komponenten, die nur ihre öffentliche Schnittstelle bereitstellen und Implementierungsdetails verbergen, lassen sich leichter warten, aktualisieren und kombinieren. Die Reibung beim Testen zeigt eine Lücke im DOM-Zugriffsmodell der Testwerkzeuge, keinen Fehler in Shadow DOM selbst. Playwrights Shadow-Grenzen durchdringende Locators und semantische Locators sind Fortschritte hin zu einem Testmodell, das mit der Kapselung arbeitet.

Für die Praxis: Verwenden Sie Pierce-Selektoren für offene Shadow Roots, prüfen Sie bei geschlossenen Roots beobachtbare Zustände statt interner Strukturen und zentralisieren Sie Locators zur Shadow-Traversierung. So erfordern Komponentenaktualisierungen Änderungen an einer Stelle statt in der gesamten Testsuite. Shadow DOM wird bleiben – Automatisierungsansätze müssen darauf ausgerichtet sein.

Verwandte Beiträge

Weiterführende Beiträge