Lidando com o Shadow DOM em testes automatizados de navegador
O Shadow DOM fornece encapsulamento real para componentes web. Estilos não vazam para dentro, seletores não vazam para fora e os detalhes internos dos componentes permanecem privados. Sistemas de design de grande porte — o framework Katal da Amazon, o Salesforce Lightning Web Components e o Material Web Components — usam Shadow DOM amplamente, justamente porque esse encapsulamento torna os componentes previsíveis e portáteis.
O problema é que o mesmo encapsulamento que torna o Shadow DOM útil em produção o torna profundamente problemático em testes automatizados. A maioria dos frameworks de automação, incluindo Selenium, Cypress e em certa medida o Playwright, foi construída em torno de document.querySelector, e o Shadow DOM é especificamente projetado para ser invisível ao document.querySelector.
Por que o Shadow DOM quebra a automação
O problema central é a fronteira do Shadow DOM. Quando um componente renderiza em uma shadow root, sua subárvore DOM existe em um fragmento de documento separado que consultas DOM normais não podem percorrer. Uma chamada como document.querySelector(‘.checkout-button’) não encontrará um elemento com essa classe se ele estiver dentro de uma shadow root, mesmo que esteja visualmente presente na página e totalmente interativo para um usuário humano.
Isso não é um bug do navegador. É a especificação funcionando conforme projetado. O encapsulamento do Shadow DOM impede que seletores CSS e consultas DOM acessem o interior dos componentes a partir de fora; o JavaScript externo só consegue acessá-lo por meio de uma referência explícita, como a propriedade shadowRoot de uma raiz aberta. Os métodos querySelector disponíveis em document ou em elementos DOM normais param nas fronteiras do Shadow DOM. Considere esta estrutura:
<!-- Light DOM -->
<katal-button>
#shadow-root
<button class="kds-button">Add to cart</button>
</katal-button>
Um seletor como document.querySelector(‘katal-button .kds-button’) retorna null. O elemento existe no DOM, mas a consulta não pode alcançá-lo. Frameworks de automação que dependem de uma única string de seletor a partir da raiz do documento falham silenciosamente aqui — o elemento não está faltando, está encapsulado.
Shadow roots abertas e fechadas
Shadow roots vêm em dois modos: open e closed. Essa distinção determina quais abordagens de automação estão disponíveis.
Uma shadow root aberta expõe seus elementos internos através da propriedade shadowRoot no elemento host:
const host = document.querySelector('katal-button');
const button = host.shadowRoot.querySelector('.kds-button');
Uma shadow root fechada retorna null para element.shadowRoot. Alguns sistemas de componentes empresariais usam shadow roots fechadas em produção para evitar que código externo dependa de detalhes de implementação interna. Shadow roots fechadas existem especificamente para bloquear o padrão acima.
Na prática, a maioria dos engenheiros de automação encontra shadow roots abertas com mais frequência. Elementos nativos do navegador como <input type="date"> e <video> também renderizam seus controles em árvores shadow, mas essas raízes do agente do usuário não são expostas aos scripts da página nem aos localizadores do Playwright. Shadow roots fechadas aparecem principalmente em sistemas de design empresariais e requerem uma abordagem fundamentalmente diferente, que abordaremos separadamente.
Travessia manual com shadowRoot.querySelector
Para shadow roots abertas, a abordagem direta é a travessia explícita. Em vez de um seletor a partir da raiz do documento, você navega pelas fronteiras do Shadow DOM em etapas. No Playwright, o método evaluate executa JavaScript no contexto da página e pode percorrer shadow roots diretamente:
// Não funciona — evaluate não pode retornar elementos DOM por valor
const button = await page.evaluate(() => {
const host = document.querySelector('katal-button');
return host?.shadowRoot?.querySelector('.kds-button');
});
// Funciona — evaluateHandle retorna um handle para o elemento ativo
const buttonHandle = await page.evaluateHandle(() => {
const host = document.querySelector('katal-button');
return host?.shadowRoot?.querySelector('.kds-button');
});
await buttonHandle.asElement()?.click();
A distinção entre evaluate e evaluateHandle é relevante aqui. evaluate serializa o valor de retorno pela fronteira entre o navegador e o Node.js — nós DOM não são serializáveis, então você obtém undefined em vez do elemento. evaluateHandle retorna um objeto handle que mantém a referência ativa no processo do navegador, permitindo chamadas de interação subsequentes.
Mas, na prática, esse padrão é frágil. Uma atualização de componente que renderize novamente ou substitua o nó interno deixa o handle apontando para um elemento desconectado do DOM. Para aninhamento profundo — uma shadow root dentro de uma shadow root dentro de outra shadow root — o código de travessia se torna verboso e frágil. Existe uma abordagem melhor.
O seletor pierce
Os localizadores CSS do Playwright atravessam shadow roots abertas por padrão, portanto page.locator('katal-button .kds-button') já alcança o botão. O token >> encadeia seletores, consultando cada parte em relação à correspondência anterior, o que torna explícita a estrutura que vai do host aos seus elementos internos:
// Atravessa a shadow root do katal-button
const button = page.locator('katal-button >> .kds-button');
await button.click();
Com >>, o lado esquerdo localiza o shadow host e o lado direito faz a consulta em relação a ele; como o mecanismo CSS do Playwright atravessa shadow roots abertas, isso inclui a shadow root do host e quaisquer raízes abertas aninhadas dentro dela. Para aninhamento mais profundo, o encadeamento funciona:
// Três níveis de profundidade
const input = page.locator('my-form >> my-field >> input[type="text"]');
Isso é consideravelmente mais limpo do que a travessia manual e sobrevive melhor a atualizações estruturais de componentes do que padrões evaluateHandle. A lógica de repetição integrada do Playwright significa que o localizador tentará novamente a resolução até que o elemento apareça, o que lida automaticamente com renderização assíncrona.
Os localizadores semânticos do Playwright — getByRole, getByLabel, getByText — também atravessam shadow roots abertas; no Playwright, todos os tipos de localizadores fazem isso, exceto XPath. Para componentes compatíveis com ARIA, esta é a opção mais fácil de manter, pois testa o comportamento em vez da estrutura:
// Funciona se o botão do shadow DOM tem papel acessível e texto
await page.getByRole('button', { name: 'Add to cart' }).click();
A limitação dos seletores pierce é que eles funcionam apenas com shadow roots abertas. Shadow roots fechadas não são compatíveis com os localizadores do Playwright.
Aguardando a montagem das shadow roots
O Shadow DOM introduz um problema de temporização que os testes de light DOM raramente apresentam. Quando um elemento personalizado passa a usar sua implementação registrada e anexa uma shadow root, há dois eventos assíncronos distintos: o elemento aparecendo no DOM e a shadow root sendo preenchida com conteúdo. Aguardar o elemento host não garante que o conteúdo do Shadow DOM esteja pronto.
// Não confiável — aguarda o host, não o conteúdo shadow
await page.waitForSelector('katal-button');
// Isso ainda pode falhar imediatamente depois
await page.locator('katal-button >> .kds-button').click();
O padrão correto aguarda diretamente pelo conteúdo do Shadow DOM. Como os localizadores do Playwright tentam novamente automaticamente, locator.click() já lida com esse caso na maioria das situações — mas para componentes orientados por dados que buscam configuração antes de renderizar, o tempo limite para novas tentativas pode expirar antes que o conteúdo apareça. Uma espera explícita com um seletor significativo é mais robusta:
// Aguarda um elemento Shadow DOM específico ficar visível
await page.locator('katal-button >> .kds-button').waitFor({ state: 'visible' });
await page.locator('katal-button >> .kds-button').click();
// Para componentes carregados assincronamente, aguarda um indicador de dados prontos
await page.locator('katal-product-card >> [data-loaded="true"]').waitFor();
A chamada waitFor explícita também produz mensagens de falha mais informativas. O esgotamento do tempo limite em waitForSelector(‘katal-button’) diz que o host não foi encontrado; o esgotamento do tempo limite em waitFor({ state: ‘visible’ }) no localizador pierce diz que o conteúdo do Shadow DOM especificamente não estava pronto. Essa distinção importa ao depurar testes instáveis.
Propagação de eventos entre shadow roots
O tratamento de eventos através de fronteiras do Shadow DOM tem sutilezas que afetam como os testes de interação se comportam. Eventos de interface do usuário disparados pelo navegador, como os de clique, entrada e teclado, são compostos (composed: true), o que significa que atravessam as fronteiras do Shadow DOM e se propagam por bubbling até o documento externo. A maioria dos outros eventos, incluindo os sintéticos criados sem composed: true, não é composta.
Mas a propriedade target passa a apontar para outro elemento. Um ouvinte de eventos no elemento shadow host vê o próprio host como target, não o elemento interno do Shadow DOM onde o evento se originou. Isso importa se seus testes verificam alvos de eventos, ou se o código do aplicativo usa event.target para identificar com qual elemento houve interação:
host.addEventListener('click', (e) => {
console.log(e.target); // registra o elemento host, não o botão interno
console.log(e.composedPath()); // registra o caminho completo incluindo internos shadow
});
Para shadow roots abertas, event.composedPath() é a maneira confiável de inspecionar o que realmente aconteceu durante a interação; para raízes fechadas, os listeners externos ao host veem o caminho terminar no host. Para testes que verificam o comportamento de disparo de eventos, verificar composedPath() em vez de target mostra corretamente o que ocorreu.
Eventos personalizados são mais problemáticos. Eles assumem composed: false como padrão, o que significa que não atravessam fronteiras do Shadow DOM. Um componente que dispara um evento personalizado interno sem definir explicitamente composed: true não será observável de fora do shadow host:
// Dentro do componente — este evento NÃO chegará a ouvintes externos
this.shadowRoot.dispatchEvent(new CustomEvent('katal-select', {
bubbles: true,
composed: false, // padrão — evento permanece dentro da shadow root
detail: { value: selectedItem },
}));
// Fora do componente — este ouvinte nunca é acionado
host.addEventListener('katal-select', handler); // nunca chamado
Quando um componente não expõe eventos ao light DOM, testar o disparo de eventos de fora é impossível por meios padrão. As opções práticas são: usar page.evaluate para adicionar um observador dentro da shadow root antes da ação, ou testar a mudança de estado observável que deve resultar do evento em vez do próprio evento. Este último geralmente é mais significativo de qualquer forma — testar que uma seleção é refletida nos atributos públicos do componente é mais resiliente do que testar que um evento foi disparado.
O que as shadow roots fechadas exigem
Para shadow roots fechadas, nenhuma das abordagens baseadas em seletores funciona. A propriedade shadowRoot retorna null, seletores pierce não podem atravessar um limite fechado e a travessia evaluateHandle falha no mesmo ponto. As alternativas exigem abordagens diferentes.
Use a API pública. Componentes web bem projetados expõem atributos, propriedades ou slots que controlam seu estado sem exigir acesso interno. Se um componente suporta aria-checked, clicar nele e verificar o atributo aria é adequado para automação e semanticamente correto. Testar o que o componente expõe é mais fácil de manter do que testar o que ele oculta.
Teste no nível do componente. Componentes Shadow DOM geralmente são testáveis isoladamente usando ferramentas como @web/test-runner ou os utilitários de teste do Lit, que fornecem um ambiente controlado para testar o componente diretamente pela sua própria API. Essas ferramentas não abrem uma raiz fechada por si só, mas facilitam testar a API pública isoladamente. Testes end-to-end em nível de página devem verificar o comportamento de integração; testes em nível de componente podem verificar a correção interna.
Negocie com o autor do componente. Shadow roots fechadas são uma escolha deliberada. Se uma biblioteca de componentes está bloqueando a automação de testes legítima sem fornecer interfaces públicas testáveis, isso é uma preocupação de qualidade válida que vale a pena levantar. Modo shadow aberto com documentação clara sobre quais detalhes internos são estáveis é um meio-termo razoável.
Centralizando localizadores do Shadow DOM
Os padrões que funcionam de forma confiável em suítes de testes de produção evitam soluções engenhosas de travessia embutidas nos testes em favor de definições de localizadores explícitas e centralizadas. Um módulo auxiliar que encapsula caminhos pierce comuns mantém os testes legíveis e isola o impacto das atualizações 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`);
// Nos testes
await katalButton(page, 'Add to cart').click();
await katalInput(page, 'email').fill('[email protected]');
Quando o Katal atualiza sua estrutura interna, você atualiza uma função em vez de procurar em cada arquivo de teste. Este é o mesmo princípio que torna os Page Object Models úteis, aplicado especificamente à camada de encapsulamento que o Shadow DOM introduz. O limite do componente se torna o limite de abstração para seus auxiliares de teste.
O problema subjacente
A dificuldade de automação do Shadow DOM é um sinal de que as ferramentas de teste não acompanharam totalmente o modelo de componentes. Frameworks construídos em torno do acesso global ao querySelector assumem um DOM plano e acessível — o oposto do que o Shadow DOM foi projetado para fornecer.
Mas, na prática, o encapsulamento está correto. Componentes que expõem apenas sua interface pública e ocultam detalhes de implementação são mais fáceis de manter, atualizar e compor. O atrito nos testes reflete uma lacuna na forma como as ferramentas de teste concebem o acesso ao DOM, não uma falha no próprio Shadow DOM. Os localizadores do Playwright que atravessam shadow roots e seus localizadores semânticos representam progresso em direção a um modelo de teste que trabalha com encapsulamento em vez de contra ele.
O conselho prático: use seletores pierce para shadow roots abertas, teste estado observável em vez de estrutura interna para shadow roots fechadas, e centralize localizadores que atravessam o Shadow DOM para que atualizações de componentes exijam mudanças em um lugar em vez de em toda uma suíte de testes. Shadow DOM veio para ficar — abordagens de automação precisam ser construídas em torno dessa realidade.
Artigos relacionados
- Automação de navegadores em escala sem bloqueios — executar o Playwright de forma confiável em sites em produção