シャドウDOMサポートと再利用可能なコンポーネントオブジェクト
シャドウDOMは、ウェブコンポーネントを構成する主要なブラウザ機能の1つです。ウェブコンポーネントは、再利用可能な要素を構築するための非常に優れた方法であり、完全なウェブアプリケーションまでスケールアップできます。シャドウDOMの力を発揮する機能であるスタイルのカプセル化は、E2EテストやUIテストにおいて少し厄介なものでした。しかし、WebdriverIO v5.5.0で、2つの新しいコマンド、shadow$
とshadow$$
を介したシャドウDOMの組み込みサポートが導入されたため、状況は少し楽になりました。それらがどのようなものか見ていきましょう。
履歴
v0のシャドウDOM仕様では、/deep/
セレクタが登場しました。この特別なセレクタを使用すると、要素のshadowRoot
内をクエリできます。ここでは、my-element
カスタム要素のshadowRoot
内にあるボタンをクエリしています。
$('body my-element /deep/ button');
/deep/
セレクタは短命でした、そしていつか置き換えられると噂されています。
/deep/
が非推奨になり、その後削除されたため、開発者はシャドウ要素を取得する他の方法を見つけました。一般的なアプローチは、WebdriverIOでカスタムコマンドを使用することでした。これらのコマンドは、execute
コマンドを使用して、要素を見つけるためにquerySelectorとshadowRoot.querySelector呼び出しを連結しました。これは一般的に機能し、基本的な文字列クエリではなく、クエリが配列に入れられました。配列内の各文字列はシャドウ境界を表していました。これらのコマンドの使用方法は次のようになります。
const myButton = browser.shadowDomElement(['body my-element', 'button']);
/deep/
セレクタとJavaScriptアプローチの両方の欠点は、要素を見つけるために、クエリが常にドキュメントレベルから開始する必要があることでした。これにより、テストはやや扱いにくく、保守が困難になりました。このようなコードは珍しくありませんでした。
it('submits the form', ()=> {
const myInput = browser.shadowDomElement(BASE_SELECTOR.concat(['my-deeply-nested-element', 'input']));
const myButton = browser.shadowDomElement(BASE_SELECTOR.concat(['my-deeply-nested-element', 'button']));
myInput.setValue('test');
myButton.click();
});
shadow$
コマンドとshadow$$
コマンド
これらのコマンドは、WebdriverIO v5の関数セレクタを使用する機能である$
コマンドを利用しています。既存の$
コマンドと$$
コマンドと同様に、要素に対して呼び出しますが、要素のライトDOMをクエリするのではなく、要素のシャドウDOMをクエリします(何らかの理由でポリフィルを使用していない場合は、ライトDOMのクエリにフォールバックします)。
要素コマンドであるため、クエリを作成する際にルートドキュメントから開始する必要がなくなりました。要素を取得したら、element.shadow$('selector')
を呼び出すことで、その要素のshadowRoot内で、指定されたセレクタに一致する要素をクエリできます。任意の要素から、必要なだけ$
コマンドとshadow$
コマンドをチェーンできます。
ページオブジェクト
対応する$
と$$
と同様に、シャドウコマンドを使用すると、ページオブジェクトの作成、読み取り、保守が容易になります。次のようなページで作業していると仮定しましょう。
<body>
<my-app>
<app-login></app-login>
</my-app>
</body>
これは、my-app
とapp-login
の2つのカスタム要素を使用しています。my-app
はbody
のライトDOMにあり、そのライトDOM内にはapp-login
要素があります。このページとやり取りするためのページオブジェクトの例を以下に示します。
class LoginPage {
open() {
browser.url('/login');
}
get app() {
// my-app lives in the document's light DOM
return browser.$('my-app');
}
get login() {
// app-login lives in my-app's light DOM
return this.app.$('app-login');
}
get usernameInput() {
// the username input is inside app-login's shadow DOM
return this.login.shadow$('input #username');
}
get passwordInput() {
// the password input is inside app-login's shadow DOM
return this.login.shadow$('input[type=password]');
}
get submitButton() {
// the submit button is inside app-login's shadow DOM
return this.login.shadow$('button[type=submit]');
}
login(username, password) {
this.login.setValue(username);
this.username.setValue(password);
this.submitButton.click();
}
}
上記の例では、ページオブジェクトのゲッターメソッドを利用して、アプリケーションのさまざまな部分にさらに深く掘り下げることができる様子を示しています。これにより、セレクタはきれいに整理されます。たとえば、app-login
要素の移動を決定した場合、変更する必要があるのは1つのセレクタだけです。
コンポーネントオブジェクト
ページオブジェクトパターンに従うことは、それ自体で非常に強力です。ウェブコンポーネントの大きな利点は、再利用可能な要素を作成できることです。ただし、ページオブジェクトのみを使用する際の欠点は、ウェブコンポーネントにカプセル化された要素とやり取りするために、異なるページオブジェクトでコードとセレクタを繰り返す可能性があることです。
コンポーネントオブジェクトパターンは、その繰り返しを減らし、コンポーネントのAPIを独自のオブジェクトに移動しようとします。要素のシャドウDOMとやり取りするには、最初にホスト要素が必要であることを知っています。コンポーネントオブジェクトの基本クラスを使用すると、これは非常に簡単になります。これは、コンストラクタでhost
要素を受け取り、その要素のクエリをブラウザオブジェクトまで展開する、基本的なコンポーネント基本クラスです。これにより、ページ自体について何も知らなくても、多くのページオブジェクト(または他のコンポーネントオブジェクト)で再利用できます。
class Component {
constructor(host) {
const selectors = [];
// Crawl back to the browser object, and cache all selectors
while (host.elementId && host.parent) {
selectors.push(host.selector);
host = host.parent;
}
selectors.reverse();
this.selectors_ = selectors;
}
get host() {
// Beginning with the browser object, reselect each element
return this.selectors_.reduce((element, selector) => element.$(selector), browser);
}
}
module.exports = Component;
次に、app-login
コンポーネントのサブクラスを作成できます。
const Component = require('./component');
class Login extends Component {
get usernameInput() {
return this.host.shadow$('input #username');
}
get passwordInput() {
return this.host.shadow$('input[type=password]');
}
get submitButton() {
return this.login.shadow$('button[type=submit]');
}
login(username, password) {
this.usernameInput.setValue(username);
this.passwordInput.setValue(password);
this.submitButton.click();
}
}
module.exports = Login;
最後に、ログインページオブジェクト内でコンポーネントオブジェクトを使用できます。
const Login = require('./components/login');
class LoginPage {
open() {
browser.url('/login');
}
get app() {
return browser.$('my-app');
}
get loginComponent() {
// return a new instance of our login component object
return new Login(this.app.$('app-login'));
}
}
このコンポーネントオブジェクトは、コンポーネントの構造について何も知らなくても、app-login
ウェブコンポーネントを使用するアプリケーションの任意のページまたはセクションのテストで使用できます。後でウェブコンポーネントの内部構造を変更することにした場合、コンポーネントオブジェクトのみを更新する必要があります。
将来
現在、WebDriverプロトコルはシャドウDOMをネイティブにサポートしていませんが、進捗が報告されています。仕様が確定したら、WebdriverIOはその仕様を実装します。shadow
コマンドは内部的に変更される可能性がありますが、今日の使用方法とほぼ同じであると確信しており、それらを使用するテストコードはほとんどリファクタリングの必要がないと確信しています。
ブラウザのサポート
IE11-Edge:IEまたはEdgeではシャドウDOMはサポートされていませんが、ポリフィルを使用できます。シャドウコマンドはポリフィルと連携して動作します。
Firefox:Firefoxの入力フィールドに対してsetValue(value)
を呼び出すと、「キーボードからアクセスできません」というエラーが発生します。今のところの回避策は、browser.execute(function)
を使用して入力フィールドの値を設定するカスタムコマンド(またはコンポーネントオブジェクトのメソッド)を使用することです。
Safari:WebdriverIOには、古い要素参照の問題を軽減するための安全メカニズムがいくつかあります。これは非常に優れた機能ですが、残念ながらSafariのWebDriverは、他のブラウザでは古い要素参照であるものとやり取りしようとした場合に、適切なエラー応答を提供しません。これは残念ですが、同時に、要素参照をキャッシュすることは一般的に悪い習慣です。古い要素参照は、上記で概説されているページオブジェクトとコンポーネントオブジェクトのパターンを使用することで、通常完全に軽減されます。
Chrome:問題なく動作します。🎉