Cómo manejar Shadow DOM en pruebas automatizadas de navegador
Shadow DOM ofrece una encapsulación real para los componentes web. Los estilos externos no entran, los selectores internos no salen y los detalles internos de los componentes permanecen privados. Los grandes sistemas de diseño —el framework Katal de Amazon, Salesforce Lightning Web Components y Material Web Components— utilizan ampliamente Shadow DOM precisamente porque esa encapsulación hace que los componentes sean predecibles y portables.
El problema es que la misma encapsulación que hace útil a Shadow DOM en producción plantea serias dificultades para las pruebas automatizadas. La mayoría de los frameworks de automatización, incluidos Selenium, Cypress y, en cierta medida, Playwright, se diseñaron en torno a document.querySelector, y Shadow DOM está diseñado expresamente para ser invisible a document.querySelector.
Por qué Shadow DOM dificulta la automatización
El problema central es el límite de encapsulación. Cuando un componente se renderiza en una raíz de Shadow DOM, su subárbol DOM existe en un fragmento de documento independiente que las consultas DOM normales no pueden recorrer. Una llamada como document.querySelector(‘.checkout-button’) no encontrará un elemento con esa clase si está dentro de una raíz de Shadow DOM, aunque sea visible en la página y una persona pueda interactuar con él sin problemas.
No es un fallo del navegador: la especificación funciona tal como se diseñó. La encapsulación de Shadow DOM impide que los selectores CSS y las consultas DOM accedan desde fuera al interior de los componentes; JavaScript externo solo puede acceder mediante una referencia explícita, como la propiedad shadowRoot de una raíz abierta. Los métodos querySelector disponibles en document o en elementos DOM normales se detienen en los límites de Shadow DOM. Considere esta estructura:
<!-- Light DOM -->
<katal-button>
#shadow-root
<button class="kds-button">Add to cart</button>
</katal-button>
Un selector como document.querySelector(‘katal-button .kds-button’) devuelve null. El elemento existe en el DOM, pero la consulta no puede acceder a él. Los frameworks de automatización que dependen de una única cadena de selector desde la raíz del documento fallan aquí sin indicarlo: el elemento no falta, está encapsulado.
Raíces de Shadow DOM abiertas y cerradas
Las raíces de Shadow DOM tienen dos modos: open y closed. Esta distinción determina en gran medida qué enfoques de automatización están disponibles.
Una raíz abierta expone su contenido interno mediante la propiedad shadowRoot del elemento anfitrión:
const host = document.querySelector('katal-button');
const button = host.shadowRoot.querySelector('.kds-button');
Una raíz cerrada devuelve null para element.shadowRoot. Algunos sistemas de componentes empresariales utilizan raíces cerradas en producción para impedir que el código externo dependa de detalles de implementación internos. Las raíces cerradas existen específicamente para bloquear el patrón anterior.
En la práctica, la mayoría de los ingenieros de automatización encuentran raíces abiertas con mayor frecuencia. Los elementos nativos del navegador, como <input type="date"> y <video>, también renderizan sus controles en árboles de Shadow DOM, pero esas raíces del agente de usuario no se exponen a los scripts de la página ni a los localizadores de Playwright. Las raíces cerradas aparecen principalmente en sistemas de diseño empresariales y requieren un enfoque radicalmente distinto, que abordaremos por separado.
Recorrido manual con shadowRoot.querySelector
Para las raíces abiertas, el enfoque más directo es recorrerlas explícitamente. En lugar de utilizar un único selector desde la raíz del documento, se atraviesan los límites de Shadow DOM por pasos. En Playwright, el método evaluate ejecuta JavaScript en el contexto de la página y puede recorrer directamente las raíces de Shadow DOM:
// 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();
La distinción entre evaluate y evaluateHandle es importante aquí. evaluate serializa el valor devuelto para transferirlo del navegador a Node.js. Los nodos DOM no son serializables, por lo que se obtiene undefined en lugar del elemento. evaluateHandle devuelve un objeto de referencia que mantiene viva la referencia en el proceso del navegador y permite realizar llamadas de interacción posteriores.
Sin embargo, este patrón es frágil. Una actualización del componente que vuelva a renderizar o reemplace el nodo interno deja la referencia apuntando a un elemento desconectado del DOM. Con un anidamiento profundo —una raíz dentro de otra, que a su vez está dentro de una tercera—, el código de recorrido se vuelve extenso y frágil. Hay un enfoque mejor.
El selector que atraviesa Shadow DOM
Los localizadores CSS de Playwright atraviesan las raíces abiertas de Shadow DOM de forma predeterminada, por lo que page.locator('katal-button .kds-button') ya alcanza el botón. El token >> encadena selectores y consulta cada parte con respecto a la coincidencia anterior, haciendo explícita la estructura de anfitrión y elementos internos:
// Pierce through the shadow root of katal-button
const button = page.locator('katal-button >> .kds-button');
await button.click();
Con >>, la parte izquierda localiza el elemento anfitrión y la derecha consulta con respecto a él; como el motor CSS de Playwright atraviesa raíces abiertas de Shadow DOM, esto incluye la raíz del anfitrión y cualquier raíz abierta anidada en ella. Para anidamientos más profundos, se pueden encadenar selectores:
// Three levels deep
const input = page.locator('my-form >> my-field >> input[type="text"]');
Este enfoque es considerablemente más claro que el recorrido manual y resiste mejor las actualizaciones de la estructura del componente que los patrones basados en evaluateHandle . La lógica de reintentos integrada en Playwright hace que el localizador vuelva a intentar encontrar el elemento hasta que aparezca, lo que gestiona automáticamente el renderizado asíncrono.
Los localizadores semánticos de Playwright — getByRole, getByLabel, getByText — también atraviesan las raíces abiertas de Shadow DOM; en Playwright, todos los tipos de localizadores lo hacen, excepto XPath. Para componentes que cumplen con ARIA, esta es la opción más fácil de mantener, ya que prueba el comportamiento en lugar de la estructura:
// Works if the shadow DOM button has accessible role and text
await page.getByRole('button', { name: 'Add to cart' }).click();
La limitación de estos selectores es que solo funcionan con raíces abiertas. Los localizadores de Playwright no admiten raíces cerradas.
Esperar a que se monten las raíces de Shadow DOM
Shadow DOM introduce un problema de sincronización que rara vez aparece al probar el DOM externo, o light DOM. Cuando un elemento personalizado se inicializa y adjunta una raíz de Shadow DOM, ocurren dos eventos asíncronos distintos: el elemento aparece en el DOM y la raíz se llena de contenido. Esperar al elemento anfitrión no garantiza que el contenido interno esté listo.
// 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();
El patrón correcto espera directamente al contenido de Shadow DOM. Como los localizadores de Playwright reintentan automáticamente, locator.click() ya gestiona este caso en la mayoría de las situaciones. Sin embargo, en componentes que obtienen datos de configuración antes de renderizarse, el tiempo de espera máximo de los reintentos puede agotarse antes de que aparezca el contenido. Una espera explícita con un selector significativo es más fiable:
// 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();
La llamada explícita a waitFor también produce mensajes de error más informativos. Agotar el tiempo de espera en waitForSelector(‘katal-button’) indica que no se encontró el elemento anfitrión; agotarlo en waitFor({ state: ‘visible’ }) sobre el localizador que atraviesa Shadow DOM indica que el contenido interno no estaba listo. Esta distinción importa al depurar pruebas que fallan de forma intermitente.
Propagación de eventos entre raíces de Shadow DOM
La gestión de eventos a través de los límites de Shadow DOM tiene matices que afectan al comportamiento de las pruebas de interacción. Los eventos de interfaz de usuario enviados por el navegador, como los de clic, entrada y teclado, son compuestos (composed: true), lo que significa que se propagan a través de esos límites y ascienden hasta el documento externo. La mayoría de los demás eventos, incluidos los sintéticos creados sin composed: true, no lo son.
Sin embargo, la propiedad target se redirige a otro destino. Un receptor de eventos en el elemento anfitrión ve al propio anfitrión como destino, en lugar del elemento interno de Shadow DOM donde se originó el evento. Esto importa si sus pruebas verifican los destinos de los eventos o si el código de la aplicación utiliza event.target para identificar el elemento con el que se interactuó:
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
});
En las raíces abiertas, event.composedPath() es la forma fiable de inspeccionar lo que ocurrió durante la interacción; en las cerradas, los escuchadores externos al anfitrión ven que la ruta se detiene en este. En las pruebas que verifican el comportamiento del envío de eventos, comprobar composedPath() en lugar de target ofrece una imagen correcta.
Los eventos personalizados plantean más problemas. Su valor predeterminado es composed: false, lo que significa que no cruzan los límites de Shadow DOM. Si un componente emite un evento personalizado interno sin establecer explícitamente composed: true , ese evento no se podrá observar desde fuera del elemento anfitrión:
// 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
Cuando un componente no expone eventos al light DOM, probar desde fuera la emisión de eventos es imposible por los medios habituales. Las opciones prácticas son utilizar page.evaluate para añadir un observador dentro de la raíz antes de la acción, o probar el cambio de estado observable que debería resultar del evento en lugar del evento mismo. Esta última opción suele ser más significativa: comprobar que una selección se refleja en los atributos públicos del componente resiste mejor los cambios que comprobar que se emitió un evento.
Qué requieren las raíces cerradas
Para las raíces cerradas, ninguno de los enfoques basados en selectores funciona. La propiedad shadowRoot devuelve null, los selectores no pueden atravesar un límite cerrado y el recorrido mediante evaluateHandle falla en el mismo punto. Las alternativas son distintas.
Utilice la API pública. Los componentes web bien diseñados exponen atributos, propiedades o slots que controlan su estado sin necesidad de acceso interno. Si un componente admite aria-checked, hacer clic en él y verificar el atributo ARIA facilita la automatización y es semánticamente correcto. Probar lo que el componente expone es más fácil de mantener que probar lo que oculta.
Pruebe a nivel de componente. Los componentes de Shadow DOM suelen poder probarse de forma aislada con herramientas como @web/test-runner o las utilidades de pruebas de Lit, que ofrecen un entorno controlado para probar el componente directamente mediante su propia API. No abren una raíz cerrada por sí mismas, pero facilitan probar la API pública de forma aislada. Las pruebas de extremo a extremo de la página deben verificar el comportamiento de integración; las pruebas del componente pueden verificar su funcionamiento interno.
Hable con el autor del componente. Las raíces cerradas son una decisión deliberada. Si una biblioteca de componentes bloquea una automatización de pruebas legítima sin proporcionar interfaces públicas que permitan probarla, es un problema de calidad que merece plantearse. Un modo abierto con documentación clara sobre qué detalles internos son estables es un término medio razonable.
Centralizar los localizadores de Shadow DOM
Los patrones que funcionan de forma fiable en las suites de pruebas de producción evitan recorridos ingeniosos insertados en cada prueba y prefieren definiciones de localizadores explícitas y centralizadas. Un módulo auxiliar que encapsule las rutas habituales a través de Shadow DOM mantiene las pruebas legibles y aísla el impacto de las actualizaciones de componentes:
// 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]');
Cuando Katal actualiza su estructura interna, se actualiza una función en lugar de buscar en todos los archivos de pruebas. Es el mismo principio que hace útiles a los modelos de objetos de página, aplicado específicamente a la capa de encapsulación que introduce Shadow DOM. El límite del componente pasa a ser el límite de abstracción de las funciones auxiliares de pruebas.
El problema de fondo
La dificultad de automatizar Shadow DOM indica que las herramientas de pruebas aún no se han adaptado por completo al modelo de componentes. Los frameworks basados en el acceso global mediante querySelector presuponen un DOM plano y accesible: lo contrario de lo que Shadow DOM está diseñado para ofrecer.
La encapsulación, sin embargo, es correcta. Los componentes que exponen únicamente su interfaz pública y ocultan los detalles de implementación son más fáciles de mantener, actualizar y combinar. La dificultad de las pruebas refleja una limitación en cómo las herramientas conciben el acceso al DOM, no un defecto de Shadow DOM. Los localizadores de Playwright que atraviesan Shadow DOM y sus localizadores semánticos representan un avance hacia un modelo de pruebas que trabaja con la encapsulación.
La recomendación práctica: utilice selectores que atraviesen las raíces abiertas, pruebe el estado observable en lugar de la estructura interna en las raíces cerradas y centralice los localizadores que recorren Shadow DOM para que las actualizaciones de componentes requieran cambios en un solo lugar. Shadow DOM ha llegado para quedarse y los enfoques de automatización deben adaptarse a esa realidad.
Artículos relacionados
- Automatización de navegadores a escala sin bloqueos — ejecutar Playwright de forma fiable en sitios en producción