tva
← コラム

ブラウザー自動テストでのShadow DOMの扱い方

Shadow DOMはWebコンポーネントを確実にカプセル化します。外部のスタイルが入り込まず、セレクターが外部に漏れず、内部実装は非公開のままです。AmazonのKatalフレームワーク、Salesforce Lightning Web Components、Material Web Componentsといった大規模なデザインシステムがShadow DOMを広く使うのも、このカプセル化によってコンポーネントの動作を予測しやすくし、移植性を高められるからです。

問題は、本番環境でShadow DOMを有用にするカプセル化が、自動テストでは大きな障壁になることです。Selenium、Cypress、そしてある程度はPlaywrightも含め、多くの自動化フレームワークはdocument.querySelectorを中心に構築されています。一方、Shadow DOMはまさにdocument.querySelectorから見えないように設計されています。

Shadow DOMで自動化が機能しなくなる理由

中心となる問題はシャドウ境界です。コンポーネントがシャドウルートに描画されると、そのDOMサブツリーは通常のDOMクエリではたどれない別のドキュメントフラグメントに存在します。document.querySelector(‘.checkout-button’)のような呼び出しでは、該当クラスの要素がシャドウルート内にある場合、画面に表示され、人が操作できる状態でも見つかりません。

これはブラウザーのバグではなく、仕様どおりの動作です。Shadow DOMのカプセル化は、外部のCSSセレクターやDOMクエリーがコンポーネント内部へアクセスすることを防ぎます。外部のJavaScriptからアクセスするには、オープンなルートのshadowRootプロパティなど、明示的な参照が必要です。querySelectorメソッドは、documentや通常のDOM要素から呼び出しても、シャドウ境界で探索を止めます。次の構造を考えてみます。

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

document.querySelector(‘katal-button .kds-button’)のようなセレクターはnullを返します。要素はDOMに存在しますが、クエリがそこまで届きません。ドキュメントのルートから単一のセレクター文字列で探索する自動化フレームワークは、ここで明確なエラーを出さずに失敗します。要素がないのではなく、カプセル化されているのです。

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

シャドウルートにはopenとclosedの2つのモードがあります。この違いは、利用できる自動化手法を大きく左右します。

オープンなシャドウルートは、ホスト要素のshadowRootプロパティを通じて内部を公開します。

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

クローズドなシャドウルートでは、element.shadowRootがnullを返します。一部の企業向けコンポーネントシステムは、本番環境でクローズドなシャドウルートを使い、外部コードが内部実装の詳細に依存しないようにしています。クローズドモードは、まさに上記のアクセス方法を防ぐために存在します。

実際には、自動化を担当するエンジニアが接するのはオープンなシャドウルートのほうが多くなります。<input type="date">や<video>などのブラウザー標準の要素も、操作用のコントロールをシャドウツリー内に描画しますが、こうしたユーザーエージェントのルートはページスクリプトにもPlaywrightのロケーターにも公開されません。クローズドなルートは主に企業向けデザインシステムで使われ、根本的に異なる対応が必要です。これについては後で説明します。

shadowRoot.querySelectorで手動でたどる

オープンなシャドウルートでは、境界を明示的にたどるのが直接的な方法です。ドキュメントのルートから1つのセレクターで探す代わりに、シャドウ境界を段階的に越えていきます。PlaywrightのevaluateメソッドはページのコンテキストでJavaScriptを実行し、シャドウルートを直接たどれます。

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

ここではevaluateとevaluateHandleの違いが重要です。evaluateは戻り値をシリアライズしてブラウザーとNode.jsの境界を越えて渡します。DOMノードはシリアライズできないため、要素ではなくundefinedが返されます。evaluateHandleはブラウザープロセス内で参照を維持するハンドルオブジェクトを返すため、その後の操作を呼び出せます。

ただし、この方法は変更に弱いものです。コンポーネントの更新で内部ノードが再描画または置換されると、ハンドルはDOMから切り離された要素を指したままになります。シャドウルートの中に別のシャドウルートがあり、さらにその中にもあるような深い入れ子では、探索コードが長くなり、壊れやすくなります。よりよい方法があります。

pierceセレクター

PlaywrightのCSSロケーターは標準でオープンなシャドウルートを越えて探索するため、page.locator('katal-button .kds-button')だけでボタンに到達できます。>>トークンはセレクターを連鎖させ、各部分を直前の一致要素を基準に検索します。これにより、ホストから内部要素へたどる構造が明示されます。

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

>>では、左側でシャドウホストを特定し、右側でそのホストを基準に検索します。PlaywrightのCSSエンジンはオープンなシャドウルートを越えて探索するため、ホストのシャドウルートと、その中に入れ子になったオープンなルートも検索対象になります。さらに深い入れ子にも、連鎖させて対応できます。

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

手動でたどる方法よりもはるかに簡潔で、evaluateHandleを使う方法よりコンポーネント構造の更新にも対応しやすくなります。Playwrightには再試行処理が組み込まれており、要素が現れるまでロケーターが解決を再試行するため、非同期の描画にも自動で対応します。

Playwrightの意味に基づくロケーター、getByRole、getByLabel、getByTextも、オープンなシャドウルートを越えて探索します。PlaywrightではXPathを除くすべての種類のロケーターがこの動作をします。ARIAに準拠するコンポーネントでは、構造ではなく振る舞いをテストできるため、最も保守しやすい選択肢です。

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

pierceセレクターの制約は、オープンなシャドウルートでしか使えないことです。Playwrightのロケーターはクローズドなシャドウルートに対応していません。

シャドウルートのマウントを待つ

Shadow DOMでは、ライトDOMのテストではほとんど表面化しないタイミングの問題が生じます。カスタム要素がアップグレードされてシャドウルートを接続するときには、要素がDOMに現れることと、シャドウルートにコンテンツが配置されることという、別々の非同期イベントがあります。ホスト要素を待つだけでは、内部のコンテンツの準備が完了したとは限りません。

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

適切なのは、シャドウ内のコンテンツを直接待つ方法です。Playwrightのロケーターは自動で再試行するため、通常はlocator.click()だけでも対応できます。ただし、描画前に設定を取得するデータ駆動型のコンポーネントでは、コンテンツが現れる前に再試行がタイムアウトすることがあります。適切なセレクターで明示的に待つほうが確実です。

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

明示的なwaitFor呼び出しは、失敗時のメッセージも分かりやすくします。waitForSelector(‘katal-button’)のタイムアウトはホストが見つからなかったことを示し、pierceロケーターのwaitFor({ state: ‘visible’ })のタイムアウトは、シャドウ内のコンテンツが準備できていなかったことを示します。この違いは、不安定なテストのデバッグに重要です。

シャドウ境界を越えるイベントの伝播

シャドウ境界を越えるイベント処理には、操作テストの動作に影響する細かな点があります。ブラウザーが発行するクリック、入力、キーボードなどのUIイベントはcomposedイベント(composed: true)であり、シャドウ境界を越えて伝播し、外側のドキュメントへバブリングします。composed: trueを指定せずに作成した合成イベントを含め、それ以外の多くのイベントはcomposedイベントではありません。

ただし、targetプロパティは再設定されます。シャドウホスト要素に登録したイベントリスナーからは、イベントが発生した内部要素ではなく、ホスト自体がターゲットに見えます。イベントのターゲットをテストで検証する場合や、アプリケーションがevent.targetで操作対象の要素を特定する場合には、この点が重要です。

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()は操作時に実際に何が起きたかを確認する確実な方法です。クローズドなルートの場合、ホストの外側のリスナーから見える経路はホストで止まります。イベントの発火動作を検証するテストでは、composedPath()を確認し、targetだけに頼らないことで、正しい状況を把握できます。

カスタムイベントにはさらに注意が必要です。初期設定はcomposed: falseなので、シャドウ境界を越えません。コンポーネントが内部でカスタムイベントを発火させる際にcomposed: trueを明示しなければ、シャドウホストの外部からは観測できません。

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

コンポーネントがライトDOMにイベントを公開しない場合、通常の方法で外部からイベントの発火をテストすることはできません。実用的な選択肢は、操作前にpage.evaluateでシャドウルート内へ監視処理を追加するか、イベント自体ではなく、その結果として観測できる状態変化をテストすることです。後者のほうが、通常は意味のある検証になります。イベントが発火したかを確認するより、選択結果がコンポーネントの公開属性に反映されたかを確認するほうが、変更にも強いテストです。

クローズドなシャドウルートへの対応

クローズドなシャドウルートでは、セレクターを使う手法はどれも機能しません。shadowRootプロパティはnullを返し、pierceセレクターはクローズドな境界を越えられず、evaluateHandleでたどっても同じ場所で失敗します。別の方法を取る必要があります。

公開APIを使います。適切に設計されたWebコンポーネントは、内部へのアクセスなしで状態を制御できる属性、プロパティ、スロットを公開しています。aria-checkedに対応するコンポーネントなら、クリックしてARIA属性を確認する方法は自動化しやすく、意味の面でも適切です。非公開の内部を調べるより、公開されている動作をテストするほうが保守しやすくなります。

コンポーネント単位でテストします。Shadow DOMコンポーネントは、@web/test-runnerやLitのテスト用ツールで単独のテストを行えることが多く、コンポーネント自身のAPIを通じて直接テストできる管理された環境を利用できます。これらのツール自体がクローズドなルートを開放するわけではありませんが、公開APIを単独でテストしやすくなります。ページ単位のE2Eテストでは統合時の振る舞いを、コンポーネント単位のテストでは内部の正しさを検証します。

コンポーネントの作者と相談します。クローズドなシャドウルートは意図的な選択です。テスト可能な公開インターフェースを提供せず、正当なテスト自動化を妨げているライブラリには、品質上の問題として相談する理由があります。どの内部要素が安定した仕様なのかを明確に文書化したうえでオープンモードを使うのは、妥当な折衷案です。

Shadow DOMのロケーターを一元管理する

実際のテストスイートで安定して動く方法は、その場で複雑な探索処理を書くのではなく、明示的なロケーター定義を一元管理することです。よく使うpierceの探索経路をヘルパーモジュールにまとめれば、テストを読みやすく保ち、コンポーネント更新の影響を限定できます。

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

Katalの内部構造が更新されても、すべてのテストファイルを探し回るのではなく、1つの関数を更新すれば済みます。これはPage Object Modelを有用にする原則を、Shadow DOMが導入するカプセル化の層に適用したものです。コンポーネントの境界が、テスト用ヘルパーの抽象化の境界になります。

根本的な問題

Shadow DOMの自動化が難しいのは、テストツールがコンポーネントモデルにまだ十分追いついていないことの表れです。グローバルなquerySelectorへのアクセスを中心とするフレームワークは、平坦でアクセス可能なDOMを前提としています。これはShadow DOMの設計目的とは逆です。

しかし、カプセル化自体は適切です。公開インターフェースだけを提供して内部実装を隠すコンポーネントは、保守、更新、組み合わせが容易になります。テストでの不便さは、Shadow DOMの欠陥ではなく、テストツールがDOMへのアクセスを捉える方法との隔たりを示しています。Playwrightのシャドウ境界を越えて探索するロケーターと意味ベースのロケーターは、カプセル化に逆らわず、それを生かすテストモデルへの前進です。

実務では、オープンなシャドウルートにはpierceセレクターを使い、クローズドなルートでは内部構造ではなく観測可能な状態を検証します。また、シャドウ境界をたどるロケーターを一元管理し、コンポーネント更新時にはテストスイート全体ではなく1か所の変更で済むようにします。Shadow DOMは今後も使われ続けるため、自動化手法もその現実を前提に設計する必要があります。

関連記事

関連記事