Protractor の Angular 同期と Angular アプリでないページのテストについて


[こわくない Protractor](http://qiita.com/shuhei/items/6973fe694d29a193f224) という記事で書けなかった Angular 同期関連についてです。

## Angular との同期

Protractor は Angular プロジェクトの一部として開発されているだけあり、Angular に特化した機能があります。その一つが Angular と同期してくれるというもの。

ここで言う同期というのは、具体的には `$digest loop`, `$timeout` や `$http` による AJAX リクエストが完了するまで待ってくれるということです。これにより、他の E2E テストフレームワークのように sleep したり、期待する値が取得できるまでポーリングするタイプの wait をする必要がなくなります。より速く不確実さを排除したテストを書けるわけです。

直接実行するには `browser.waitForAngular()` を呼びます。これはざっくりいうとブラウザ上で Angular に仕込まれている `$$testability.whenStable(callback)` というメソッドを呼ぶことで実現されています。

しかし `browser.waitForAngular()` を直接実行する必要は通常あまりないと思います。`element()` や `element.all()` などを使っていれば `click()` などのコマンドを実行する際に、勝手にこれを呼んでから要素を選択しコマンドを実行してくれるからです。

## Angular アプリでないページのテスト

とても便利な Angular との同期ですが、Angular アプリでないページで Protractor を使うにはこの挙動を off にする必要があります。

```js
browser.ignoreSynchronization = true;
```

これにより Angular アプリでないページのテストもできるようになります。

## Angular アプリでないページで待つ

Angular アプリでないページで AJAX リクエストなどを待つ必要がある場合、自分で待つためのコードを書かなければなりません。WebDriverJS で提供されている二つの方法があります。

一つは `sleep()` を使って決まった時間だけ待つというもの。単純ではありますが、場合によって処理が長くかかりすぎて失敗したり、それを避けようとして長く sleep するとテストの実行時間が長くなったりするため、あまりおすすめできません。

```js
browser.sleep(1000);
```

もう一つの方法が、ある条件が成立するまで待つというもの。その条件は関数で書くことができます。関数の中では、boolean に解決される Promise を返します。これはなかなか面倒ですね。

```js
var message = element(by.css('.message'));
browser.wait(function() {
  return message.getText().then(function(text) {
    return text === 'Hello, World!'
  });
}, 3000);
```

また関数の代わりに WebDriverJS の用意した `Condition` オブジェクトを渡すこともできます。詳細は [WebDriverJs の until 名前空間](http://selenium.googlecode.com/git/docs/api/javascript/namespace_webdriver_until.html) を見てみてください。

```js
var message = element(by.css('.message'));
browser.wait(protractor.until.elementTextIs(message, 'Hello, World!'), 3000);
```

まあ、Protractor でこういうのが必要なページをテストするかという問題はありますが・・・。

## Angular アプリとそうでないページの混在

`browser.ignoreSynchronization` を切り替えることによって Angular アプリとそうでないページを行き来するテストも可能です。このフラグが評価されるのは実際にブラウザを操作する直前ではなく `click()` などのメソッドを呼んだ直後なので、基本的にはコマンドとコマンドの間でフラグを切り替えても期待通りに動作します。

例えば、ログインページが Angular アプリでなく、ログインすると Angular アプリに遷移するようなシステムのテストを書くとします。上記の理由から以下は問題なく動きます。

```js
var loginIdField = element(by.name('login_id'));
var passwordField = element(by.name('password'));
var loginButton = element(by.buttonText('ログイン'));
var usernameDisplay = element(by.binding('username'));

browser.ignoreSynchronization = true;
// Angular と同期しない。
browser.get('index.html');

loginIdField.sendKeys('protractaro');
passwordField.sendKeys('P@ssw0rd');
loginButton.click();

browser.ignoreSynchronization = false;
// ここから Angular と同期する。
expect(usernameDisplay.getText()).toEqual('プロトラク太郎');
```

## then() の中でのコマンド発行がある場合

`then()` の中でコマンドを発行する場合は注意が必要です。以下は不自然
な例ですが失敗します。 `passwordField.sendKeys('P@ssw0rd');` の行が `browser.ignoreSynchronization = false;` の後に実行されてしまい、Angular アプリでないページで Angular を待ってしまうためです。

```js
browser.ignoreSynchronization = true;
browser.get('index.html');

// Angular と同期しない。
loginIdField.sendKeys('protractaro')
  .then(function() {
    // Angular と同期しようとする！
    passwordField.sendKeys('P@ssw0rd');
  });
// Angular と同期しない。
loginButton.click();

browser.ignoreSynchronization = false;
// Angular と同期する。
expect(usernameDisplay.getText()).toEqual('プロトラク太郎');
```

こういうケースはあまりないと思いますが、万が一必要がある場合はテストケース（`it()`）を分割するといいと思います。全てのコマンドの実行が終わってから次のテストケースに行くためです。

```js
it('should login', function() {
  browser.ignoreSynchronization = true;
  browser.get('index.html');

  // Angular と同期しない。
  loginIdField.sendKeys('protractaro')
    .then(function() {
      // Angular と同期しない。
      passwordField.sendKeys('P@ssw0rd');
    });
  // Angular と同期しない。
  loginButton.click();
});

it('should show username', function() {
  browser.ignoreSynchronization = false;
  // Angular と同期する。
  expect(usernameDisplay.getText()).toEqual('プロトラク太郎');
});
```

## まとめ

- Protractor は Angular アプリには向いている（当たり前）。
- Angular じゃないページで非同期な何かを待つ必要がある場合、つらいけどできなくはない。
- Angular アプリとそうでないページを混在させたシステムもテスト可能。
- そういう時は、Angular アプリとそうでないページでテストケースを分けると安心。