Angular Advent Calendar 2014 の 17 日目の記事です。Angular そのものではなく、AngularJS 用の E2E テストツールである Protactor について書きます。(日付越えてしまいました。すみません・・・。)
Angular チームが作っているという Protractor。Angular アプリの E2E テストをするならこれに違いないと思いつつも、使い始める前はいろいろこわい点がありました。
- コードがどこで動いているかわからなくてこわい。
- 処理がどういう順序で実行されるかわからなくてこわい。
この記事ではこれらの点について説明し、Protractor を自信を持って使えるようになることを目的としています。
読者としては以下のような方を想定します(主に先日の自分)。
- Protractor のインストールをして動かしてみた。
- 日頃から Promise とは親しんでいる(Angular の $q や jQuery の Deferred/Promise など)。
- 試しに Protractor でテストを書いてみて動いたけど、何がいつ実行されるのかよくわからない。
ちなみに Protractor は分度器という意味です。Angular は「角のある、角度に関する」という意味なので、Angular(角度)をテストする(計る)ので Protractor(分度器)なのでしょう。多分。
アーキテクチャ
Protractor で書くテストコードは Node.js のプログラムです。特に require()
しなくても element
とか by
とか browser
などがグローバル変数として設定されているので Node っぽくないのですが Node 上で動作します。ブラウザ上で動く JS ではありません。
Protractor は WebDriverJS という Node.js から Selenium を利用するためのライブラリを利用しています。Protractor は WebDriverJS を使って Selenium Server に命令を送り、それが各ブラウザの Selenium Driver に伝わり、Selenium Driver がブラウザを操作して結果を返す、という仕組みです。
expect() は Promise を待ってくれる
Protractor で用意されているほとんどのメソッドが Promise を返します。要素の数など、検証したいものも例外ではありません。
要素の数の検証を愚直に書くと以下のようになると思います。
var books = element.all(by.repeater('book in books'));
books.count()
.then(function(count) {
expect(count).toEqual(3);
});
毎回こう書くのは面倒ですね。Protractor では Jasmine の expect()
に細工がされていて、渡されたものが Promise だった場合、その Promise が解決されてから検証を行うようになっています。
expect(books.count()).toEqual(3);
便利ですね。
element(), element.all() は DOM 要素を選択しない
Protractor に関するよくある誤解の一つが、element()
などのメソッドは呼ばれた時点で要素を選択するというものです。確かに angular.element()
や jQuery の $()
などはすぐに要素を選択しますよね。
直感に反しますが、Protractor の element()
, element.all()
, element.all().first()
などのメソッドは その場で選択した要素を返すのではなく「こういう要素を選択するよ」という条件を返します(ElementFinder
)。
では、実際に要素を選択するのはいつなのでしょうか?そう、click()
などのメソッドで実際に要素に対して何かをする寸前なのです。(では click()
を呼んだ瞬間なのかというと、これもまた違います。詳しくは次項にて。)
この性質のため element()
や element.all()
の結果(ElementFinder
)は、最初に定義しておけばページの状態が変わっても 何度も使いまわすことができます。
describe('book', function() {
// この時点では、まだ要素は選択されていない。
var books = element.all(by.repeater('book in books'));
var addBookButton = element(by.buttonText('本を追加'));
it('should add a book', function() {
// 要素を選択してから数を数える。
expect(books.count()).toEqual(3);
// 本を追加ボタンを選択してからクリック。
addBookButton.click();
// ここでもう一度要素を選択してから数を数える。
// `books` が使い回せる!!!
expect(books.count()).toEqual(4);
});
});
上記の例のようにページ上のパーツに名前をつけておいてからそれらを使って振る舞いを記述することで、読みやすいテストコードを書くことができます。
さらに、ページや部品毎の ElementFinder
と関連する処理を集めた Page Object を作ってテストを構造化することが推奨されています。
書いた順番に解決される Promise
click()
や count()
などのブラウザを操作するメソッド(以下コマンドと呼びます)は非同期に実行されるため Promise を返します。メソッドを実行したタイミングでブラウザ上でクリックやキー入力が行われるわけではありません。しかし、Protractor では以下のように順番にコマンドを書くことができます。不思議ですね。
expect(books.count()).toEqual(3);
addBookButton.click();
expect(books.count()).toEqual(4);
Promise を使った非同期な処理を、愚直に順番に実行しようとすると以下のようになるかと思います。
books.count()
.then(function(count) {
expect(count).toEqual(3);
return addBookButton.click();
})
.then(function() {
return books.count();
})
.then(function(count) {
expect(count).toEqual(4);
});
これを毎回書くのは憂鬱ではないでしょうか。Protractor のベースになっている WebDriverJS では、こういった非同期処理を書きやすくするため control flow と呼ばれる Promise を管理する機構を提供しています。
click()
や sendKeys()
などメソッドを実行すると その場で処理が実行されず control flow にコマンドとして登録されます。control flow にたまったコマンドは一つずつ順番に実行されていきます。前のコマンドの promise が解決されてから、次のコマンドが実行されるわけです。
expect(books.count()).toEqual(3);
addBookButton.click();
expect(books.count()).toEqual(4);
と書いておくと、以下の順番で処理が実行されます。
- 1 個目の
books.count()
のコマンドが control flow に登録される。 -
addBookButton.click()
のコマンドが control flow に登録される。 - 2 個目の
books.count()
のコマンドが control flow に登録される。 - 1 個目の
books.count()
のコマンドが実行される。まずは Angular を待ち、要素が選択され、その次に数が数えられる。 - 数が 3 個かどうか検証される。
-
addBookButton.click()
のコマンドが実行される。まずは Angular を待ち、要素が選択され、その次にクリックされる。 - 2 個目の
books.count()
のコマンドが実行される。まずは Angular を待ち、要素が選択され、その次に数が数えられる。 - 数が 4 個かどうか検証される。
このように、control flow のおかげで then()
で明示的にチェーンをしなくても非同期処理を順番に実行することができます。
よく考えてみると Selenium WebDriver で実際のブラウザを操作するわけですから、コマンドを一個ずつ実行してくれるのは理に適っていますね。
(さらに)書いた順番に解決される Promise
コマンドの promise に then() をつけた場合の実行結果はどうなるでしょうか?
例えば、画面上の文字に応じた入力をするようなケースです。
var problem = element(by.binding('problem'));
var answerField = element(by.model('answer'));
var submitButton = elemetn(by.buttonText('回答'));
var notification = element(by.binding('notification'));
problem.getText().then(function(text) {
// `submitButton.click()` のコマンドよりも先に、以下のコマンドが実行される!
if (text === '1 + 1 = ?') {
answerField.sendKeys('2');
} else {
answerField.sendKeys('わからん');
}
});
submitButton.click();
expect(notification.getText()).toEqual('答えはまた来週');
親コマンドの then()
の中の子コマンドは、親コマンドの次のコマンドよりも前に実行されます。これにより、書いた順番にかなり近い形でコマンドが実行されます。
このあたりの詳細は WebDriverJS のドキュメントに詳しく書いてありますので、詳細が気になる方は見てみてください。
Promise 同士の比較
先の例では本の数を固定値で検証していましたが、本の追加ボタンを押す前後で 1 増えたことだけを検証したいとしましょう。
var originalCount = books.count();
addBookButton.click();
// originalCount は Promise なので足せない!!!
expect(books.count()).toEqual(originalCount + 1);
Promise が二つあるわけなので、それぞれ then()
と expect()
で実際の値を取り出します。
var originalCount = books.count();
addBookButton.click();
books.count().then(function(newCount) {
expect(originalCount).toEqual(newCount - 1);
});
以下でも良さそうですが originalCount.then()
の前に addBookButton.click()
が挟まっているので、実際の処理の順番が書いた順番とずれてしまいます。ボタンを押す前に二回目の books.count()
のコマンドが実行され、テストが失敗します。then()
は count()
などにチェーンして書かないと処理の順番がずれてしまいますね。
var originalCount = books.count();
addBookButton.click();
originalCount.then(function(original) {
expect(books.count()).toEqual(original + 1);
});
また、以下だと addBookButton
が押される前に originalCount
に実際のカウントが埋まるのですが、コマンドの実行開始よりも最後の行の評価の方が先なので toEqual(undefined + 1)
となっていまいテストが失敗します。
var originalCount;
books.count().then(function(count) {
originalCount = count;
});
addBookButton.click();
expect(books.count()).toEqual(originalCount + 1);
というように control flow の挙動を理解していれば、多少複雑なテストも迷うことなく(私は迷いならも何とかですが・・・)書けるかと思います。
テストケース、ファイルも書いた順番に実行される
describe()
の中でも、it()
で記述したテストケースは上から順に実行されます。同じブラウザで開いているページの状態を保持したまま実行されるので、一連の長い操作を複数のテストケースに分割してわかりやすい名前をつけることができます。
describe('book', function() {
var books = element.all(by.repeater('book in books'));
var addBookButton = element(by.buttonText('本を追加'));
var topBookTitle = books.first().column('title');
it('should add a book', function() {
expect(books.count()).toEqual(3);
addBookButton.click();
expect(books.count()).toEqual(4);
});
// 'it should add a book' の後で実行される!
it('should show a book title', function() {
expect(topBookTitle.getText()).toEqual('さっき追加した本');
});
});
テストのファイルも config ファイルの specs
に書いた順に実行されます。別のテストファイルの状態やデータを利用してテストを書くのに便利です。
module.exports = {
// ...
specs: [
'e2e/login.spec.js',
'e2e/author.spec.js',
'e2e/book.spec.js',
'e2e/logout.spec.js'
],
// ...
};
まとめ
- Protractor(というか WebDriverJS)は、Node.js の非同期な API の上でブラウザ上で行う処理を(できる限り)書いた順で実行する仕組みを用意している。
- Protractor は WebDriverJS に加えて Promise を待つ
expect()
や、すぐに要素を選択しないElementFinder
などを提供してくれており便利。
というわけで、Protractor こわいと思っている人は、ぜひ勇気を出して Angular の E2E テストを書いてみてください。
(時間に間に合わず Angular 特有の同期の仕組みや Locator について書けなかったのですが、それはまた今度ということで・・・。)
訂正
「Promise 同士の比較」のところで 2 つ書き方があると書いていたのですが、片方は間違いだったので訂正しました。