AngularJS 向けの E2E テストツール「Protractor」で要素を特定するアレコレ

Angular 製のアプリの E2E テストに用いられる Protractor (プロトラクター)。Protractor は Node.js で Selenium を利用するライブラリである「WebDriverJS」をベースに作られているので、API の感覚は Selenium に近い。テストコードは JavaScript のテストフレームワークである「Jasmine」の記法をほぼそのまま使えるので、Protractor はさしずめ「Selenium + Jasmine」といったところか

E2E テストでは、実際にブラウザ上で要素を特定してクリックとか文字列入力とかしてやる必要がある。この記述方法は、昔ながらの document.getElementById() だとか、jQuery の $('.hoge > .fuga') なんて書き方で DOM 操作をしたことがある人なら割と感覚が掴みやすいと思うが、他にも色々と便利な要素の特定方法があったり、かと思えば地味に引っかかるところがあったりするので、要素を特定するための書き方をまとめてみた。

基本的には公式の API リファレンスを検索してもらえば分かることだが、「こういう HTML に対してこうやって取得できる」という実例を挙げて、少し感覚を掴んでもらえたらいいかなと思う。

特定の1つの要素を取得する

Protractor で要素を1つ特定する方法の基本は、element(by.LOCATOR) という形式だ。LOCATOR 部分は id とか css とか色々あるので後述するが、要するに element = 要素( by.特定方法 ) という記述の仕方になっている。jQuery の $('#id') (= jQuery('#id')) や、jQuery を簡略化した Angular の element などと同じ書式といえる。

以下のような HTML を取得する場合を考える。

<input type="button" id="login" value="ログインボタン">

この要素には id 属性が振られているので、id 属性値で一意に特定できる。つまり、以下のように書けば良い。

const loginBtn = element(by.id('login'));

感覚的には document.getElementById('login')$('#id') とほぼ同義といえる (取得できるオブジェクト自体は通常の DOM ではなく WebElement という Protractor で操作するための型だが、要素を取得する方法としては同様、という意味)。

また、改めて後述するが、element() は複数の要素が該当する条件を渡した時に、最初の要素を1つだけ取得して返すので、element.all().first() と同じように使える。

なお、element() の戻り値は ElementFinder という型になっている。これにメソッドチェーンする形で、更に子要素を抽出したり、その要素自体を調べたり操作したりできる。

該当する複数の要素群を取得する

複数の要素群を取得するには、element.all(by.LOCATOR) と書く。例えば

<ul>
  <li class="item"><a href="1.html">ほげ</a></li>
  <li class="item"><a href="2.html">ふが</a></li>
  <li class="item"><a href="3.html">ぴよ</a></li>
</ul>

このような .item クラスが振られた要素が複数あったとしたら、

// クラス名に「item」が付与されている要素群を取得する
const items = element.all(by.css('.item'));

このように取得できる。by.css() では item ではなく .item とピリオドを付け、CSS セレクタとして認識させるようにする。ピリオドを付け忘れると要素名と解釈されることになるので注意。

HTML 中に他に li 要素がなければ、以下のように li 要素を取得しても同じ結果が得られる。

// タグ名として li 要素を指定して取得する
const liElements = element.all(by.tagName('li'));

// もしくは CSS セレクタを用いて li 要素を取得する
const items = element.all(by.css('li'));

by.css() の柔軟さ

by.css() は CSS セレクタのとおりに記述できるので、jQuery よろしく以下のように書いたりできる。

かなり柔軟に指定できるので、大抵は by.css() でやれるんじゃないかなと思う。

element.all() の戻り値は ElementArrayFinder という型になっている。メソッドチェーンによって子要素の数を数えたり、1つ要素を取り出したり、順に処理したり、ということが可能になる。クリックだとか要素内を覗くだとかいう操作はできないので注意。クリック等の操作を行うにはそこから1要素に特定する必要がある。

複数の要素群から1要素を取り出す

複数の要素群の中から1要素を取り出すには、

  1. element.all(by.LOCATOR).get(0) という形で添字を指定して取得する
  2. element.all(by.LOCATOR).first()element.all(by.LOCATOR).last() という形で最初や最後の1要素を取得する
  3. 複数の要素がヒットする場合でも element() を使用することで最初の1要素のみ取得する
  4. element.all(by.LOCATOR).element(by.LOCATOR) という形で1要素を特定する

といった方法がある。

前半の 1. と 2. の方法は、先程の element.all() の後ろに .get(INDEX) だとか .first() だとか .last() だとかを付けることで、1要素を抽出できるというモノ。

で、.get(0).first() と同じ意味になるのが、3. の element() を直で使う方法。

<ul>
  <li class="item"><a href="1.html">ほげ</a></li>
  <li class="item"><a href="2.html">ふが</a></li>
  <li class="item"><a href="3.html">ぴよ</a></li>
</ul>

先程のこの HTML を例にすると、以下は全て同じ結果が得られる。

element.all(by.css('.item')).get(0);
element.all(by.css('.item')).first();
element(by.css('.item'));

メソッドチェーンでさらに深く調べる

element()element.all() は互いにメソッドを繋げることができ、「この要素の、中にある子要素群の、中にあるこの要素」といった指定もできる。

<div id="menu">
  <ul>
    <li class="item"><a href="1.html">ほげ</a></li>
    <li class="item"><a href="2.html">ふが</a></li>
    <li class="item"><a href="3.html">ぴよ</a></li>
  </ul>
</div>

このような HTML の時に、「ほげ」を囲むリンク a 要素を取得するには、以下のように書ける。

const hogeLink = element(by.id('menu')  // #menu 要素を1つ取得する
  .all(by.css('.item'))       // #menu 配下から .item 要素群を抽出する
  .first()                    // .item 要素群の最初の1要素を取得する
  .element(by.tagName('a'));  // その要素の配下から a 要素を1つ取得する

要素を掘り下げていけるという感覚を掴んでもらえれば幸い。

色々な by 指定

by による要素特定は Locator と呼ばれ、id や css、tagName 以外にも色々な指定方法がある。

by.linkText()

例えば先程の「ほげ」なリンクを特定する方法は、by.linkText() を使うと以下のように書くことができる。

const hogeLink = element(by.linkText('ほげ'));

これで、リンク (a 要素内) の文字列と一致する要素を返してくれる。

by.buttonText()

by.linkText() と同様に、ボタンのラベルを見て要素を特定することもできる。

<input type="button" value="戻るボタン">
<button>進むボタン</button>

このような HTML の場合に、以下のようにボタンを特定できる。

const backBtn = element(by.buttonText('戻るボタン'));
const nextBtn = element(by.buttonText('進むボタン'));

input[type="submit"] なボタンでも使えたが、input[type="reset"] なボタンでは使えなかった。恐らく、画面遷移を伴うボタンやリンクはラベルで特定したい機会が多いことから、input[type="button"]input[type="submit"]button 要素のみ特定できるのだろう。また、input[type="reset"] の場合はデフォルトのラベルがブラウザ依存になることが影響するのかもしれない。

by.model()

Angular 向けに作られている Protractor なので、AngularJS でよく指定する ng-model 属性に対応した Locator もある。

<input type="text" ng-model="userName">

このように ng-model 属性を与えている HTML があったとして、

const userNameTextBox = element(by.model('userName'));

このように特定することができる。

by.repeater()

こちらも AngularJS 向けの Locator。ng-repeat 属性でループ処理している部分を取得することができる。

<!-- ng-repeat を使って items の要素を順に表示している部分 -->
<dl ng-repeat="item in items">
  <dt>{{ item.title }}</dt>
  <dd>
    <p>{{ item.price }}</p>
  </dd>
</dl>

このような HTML は以下のように取得できる。

const items = element.all(by.repeater('item in items'));

ng-repeat 属性に書いた値をそのまま by.repeater() に入れれば良い。

その他

その他使い所がありそうな Locators を挙げておく。

今回は割愛するが、by.addLocator() を使うことで独自の Locator を作成することもできる。


どのように要素を特定すると良いか

要素の特定方法はさまざまなやり方があることは分かったが、どんなやり方が良いだろうか。HTML の構造別に、優先順位を考えたい。

ベースとなる考え方は、「ちょっとした HTML の変更で使えなくなる書き方は避ける」ということであろう。例えばクラスを少し変えたりすると途端に要素が特定できなくなる指定は避けたい。

  1. by.id() : id 属性が振られている要素なら迷わずコレ。ページ中で1つしか存在しない要素と断定できるため。
  2. by.model() : ng-model 属性も id 属性に次いで一意に特定しやすい。
  3. by.repeater() など : ng-repeat 属性など、その他の AngularJS 組み込みディレクティブに対する Locator も、特徴的かつ HTML の変更の影響が少ないと考えられる。
  4. by.linkText()by.buttonText() : 画面遷移を伴うリンクやボタンはそうそうラベル変更しないだろうから、文字列での特定でも問題ないだろう。

これに当てはまらない要素の場合は、次のような方針でメソッドチェーンを組み立てると良いだろう。

メソッドチェーンで element.all(by.tagName()).element(by.css()) といった書き方にするか、CSS セレクタを使って element(by.css('ELEMENT .CLASS_NAME')) といった書き方にするかは、HTML 構造の複雑さと可読性次第かなと。速度はあまり気にならないと思う。HTML 構造が複雑な場合はある程度メソッドチェーンで崩すと良いと思う。