tva
← Insights

自動化ブラウザテストでの Shadow DOM の処理

Shadow DOM provides genuine encapsulation for web components. Styles don’t leak in, selectors don’t leak out, and component internals stay private. Large design systems — Amazon’s Katal framework, Salesforce Lightning Web Components, Material Web Components — use Shadow DOM extensively precisely because that encapsulation makes components predictable and portable.

The problem is that the same encapsulation that makes Shadow DOM useful in production makes it deeply problematic in automated testing. Most automation frameworks, including Selenium, Cypress, and to some extent Playwright, were built around document.querySelector, and Shadow DOM is specifically designed to be invisible to document.querySelector.

Shadow DOM が自動化を壊す理由

The core issue is the shadow boundary. When a component renders into a shadow root, its DOM subtree exists in a separate document fragment that normal DOM queries cannot traverse. A call like document.querySelector(‘.checkout-button’) will not find an element with that class if it lives inside a shadow root, even if it’s visually present on the page and fully interactive to a human user.

This isn’t a browser bug. It’s the spec working as designed. Shadow DOM encapsulation prevents CSS selectors and JavaScript from reaching inside components from the outside. The querySelector methods available on document or on normal DOM elements stop at shadow boundaries. Consider this structure:

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

A selector like document.querySelector(‘katal-button .kds-button’) returns null. The element exists in the DOM, but the query cannot reach it. Automation frameworks that rely on a single selector string from the document root fail silently here — the element isn’t missing, it’s encapsulated.

オープンとクローズドのシャドウルート

Shadow roots come in two modes: open and closed. This distinction matters significantly for what automation approaches are available.

An open shadow root exposes its internals through the shadowRoot property on the host element:

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

A closed shadow root returns null for element.shadowRoot. Amazon’s Katal framework, like many enterprise component systems, uses closed shadow roots in production to prevent external code from depending on internal implementation details. Closed roots exist specifically to block the pattern above.

In practice, most automation engineers encounter open shadow roots more frequently — including browser-native elements like <input type="date"> and <video>. Closed roots appear mainly in enterprise design systems and require a fundamentally different approach, which we’ll address separately.

shadowRoot.querySelector による手動トラバーサル

For open shadow roots, the straightforward approach is explicit traversal. Instead of one selector from the document root, you navigate through shadow boundaries in steps. In Playwright, the evaluate method runs JavaScript in the page context and can traverse shadow roots directly:

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

The distinction between evaluate and evaluateHandle is salient here. evaluate serializes the return value across the browser–Node.js boundary — DOM nodes aren’t serializable, so you get null. evaluateHandle returns a handle object that keeps the reference alive in the browser process, allowing subsequent interaction calls.

But in reality, this pattern is fragile. Any component update that reorganizes shadow root children invalidates the handle. For deep nesting — a shadow root inside a shadow root inside another shadow root — the traversal code becomes verbose and brittle. There’s a better approach.

pierce セレクタ

Playwright introduced the >> pierce combinator specifically for Shadow DOM traversal. It functions like the CSS descendant combinator but crosses shadow boundaries:

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

The >> combinator tells Playwright to pierce shadow roots when evaluating the selector chain. The left side locates the shadow host; the right side queries inside its shadow root, and any nested shadow roots within that. For deeper nesting, chaining works:

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

This is considerably cleaner than manual traversal and survives component structural updates better than evaluateHandle patterns. Playwright’s built-in retry logic means the locator will reattempt resolution until the element appears, which handles asynchronous rendering automatically.

Playwright’s semantic locators — getByRole, getByLabel, getByText — also pierce shadow roots automatically in recent versions. For ARIA-compliant components this is the most maintainable option, since it tests behavior rather than structure:

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

The limitation of pierce selectors is that they only work with open shadow roots. Closed shadow roots remain inaccessible to any selector-based approach.

シャドウルートのマウント待機

Shadow DOM introduces a timing problem that light DOM testing rarely surfaces. When a custom element upgrades and attaches a shadow root, there are two distinct async events: the element appearing in the DOM, and the shadow root being populated with content. Waiting for the host element doesn’t guarantee the shadow content is ready.

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

The correct pattern waits for the shadow content directly. Because Playwright locators retry automatically, locator.click() already handles this case in most situations — but for data-driven components that fetch configuration before rendering, the retry timeout may expire before content appears. An explicit wait with a meaningful selector is more robust:

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

The explicit waitFor call also produces more informative failure messages. A timeout on waitForSelector(‘katal-button’) tells you the host wasn’t found; a timeout on waitFor({ state: ‘visible’ }) on the pierce locator tells you the shadow content specifically wasn’t ready. That distinction matters when debugging flaky tests.

シャドウルート間のイベント伝播

Event handling across shadow boundaries has subtleties that affect how interaction tests behave. Most DOM events are declared with composed: true, which means they propagate across shadow boundaries and bubble into the outer document. Click events, input events, and keyboard events all behave this way.

But the target property is retargeted. An event listener on the shadow host element sees the host itself as the target, not the internal shadow DOM element where the event originated. This matters if your tests verify event targets, or if application code uses event.target to identify which element was interacted with:

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
});

event.composedPath() is the reliable way to inspect what actually happened during interaction. For tests that verify event dispatch behavior, checking composedPath() rather than target gives you the correct picture.

Custom events are more problematic. They default to composed: false, which means they do not cross shadow boundaries at all. A component dispatching an internal custom event without explicitly setting composed: true will not be observable from outside the shadow host:

// 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

When a component doesn’t surface events to the light DOM, testing event dispatch from the outside is genuinely impossible through standard means. The practical options are: use page.evaluate to add an observer inside the shadow root before the action, or test the observable state change that should result from the event rather than the event itself. The latter is usually more meaningful anyway — testing that a selection is reflected in the component’s public attributes is more resilient than testing that an event fired.

クローズドシャドウルートが必要とするもの

For closed shadow roots, none of the selector-based approaches work. The shadowRoot property returns null, pierce selectors cannot traverse a closed boundary, and evaluateHandle traversal fails at the same point. The paths forward are genuinely different.

Use the public API. Well-designed web components expose attributes, properties, or slots that control their state without requiring internal access. If a component supports aria-checked, clicking it and verifying the aria attribute is both automation-friendly and semantically correct. Testing what the component exposes is more maintainable than testing what it hides.

Test at the component level. Shadow DOM components are often testable in isolation using tools like @web/test-runner or Lit’s testing utilities, which give you a controlled environment with full shadow root access through the component’s own API. Page-level end-to-end tests should verify integration behavior; component-level tests can verify internal correctness.

Negotiate with the component author. Closed shadow roots are a deliberate choice. If a component library is blocking legitimate test automation without providing testable public interfaces, that’s a valid quality concern worth raising. Open shadow mode with clear documentation about which internals are stable is a reasonable middle ground.

Shadow DOM ロケーターの集中管理

The patterns that work reliably in production test suites avoid clever inline traversal in favor of explicit, centralised locator definitions. A helper module that encapsulates common pierce paths keeps tests readable and isolates the impact of component updates:

// 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]’);

When Katal updates its internal structure, you update one function rather than hunting through every test file. This is the same principle that makes Page Object Models useful, applied specifically to the encapsulation layer that Shadow DOM introduces. The component boundary becomes the abstraction boundary for your test helpers.

根本的な問題

Shadow DOM automation difficulty is a sign that testing tooling hasn’t fully caught up with the component model. Frameworks built around global querySelector access assume a flat, accessible DOM — the opposite of what Shadow DOM is designed to provide.

But in reality, the encapsulation is correct. Components that expose only their public interface and hide implementation details are easier to maintain, upgrade, and compose. The friction in testing reflects a gap in how test tooling conceptualises DOM access, not a flaw in Shadow DOM itself. Playwright’s pierce combinator and evolving semantic locators represent progress toward a testing model that works with encapsulation rather than against it.

The practical takeaway: use pierce selectors for open shadow roots, test observable state rather than internal structure for closed roots, and centralise shadow-traversal locators so that component updates require changes in one place rather than across an entire test suite. Shadow DOM is here to stay — automation approaches need to be built around that reality.

関連インサイト

関連記事