Protractor: AngularJSの次世代E2Eテストフレームワーク

AngularJSはテストを重視しているフレームワークだと言われています。
それは、DIが標準搭載されているのでサーバーとの通信などのテストしにくい部分を簡単にモックに差し替えることが出来たり、ユニットテストやEnd to End（E2E）テストのためのフレームワークを持っていたりするからでしょう。

そして、現在標準で含まれているE2Eテストのための機能は、今後ProtractorというSelenium WebDriverJSベースのフレームワークに移行すると発表されています。（[AngularJS 1.2 & Beyond](https://docs.google.com/presentation/d/1WHCcp3G3HxoE7b_ut_ERKJF4zQK_P4qFlESjE2E9AUQ/preview?sle=true#slide=id.p)）

これまでのE2Eテストフレームワークを捨てて新しいものに乗り替えるのには理由があります。
それは、Seleniumをベースにすることで次のような恩恵を受けられるからです。

* ブラウザを操作するためのAPIが充実
* 複数ブラウザでの実行が可能
* リモートページのテストが可能

# インストール

Protractorはnpmでインストールすることができます。

~~~
npm install protractor
~~~

npmでインストールされたディレクトリにwebdriver-managerという実行ファイルがあるので、それを使ってSelenium ServerやWebDriverをローカル環境にインストールします。

~~~
node_modules/protractor/bin/webdriver-manager update
~~~

# Grunt

GruntでProtractorを扱うために、次のプラグインを使います。
これらのプラグインは必須というわけではないので、自分のお気に入りのものを使うといいと思います。
（[grunt-start-webdriver](https://npmjs.org/package/grunt-start-webdriver)というプラグインもあります）

* grunt-protractor-runner
  * protractorのテストを実行するプラグイン。
* grunt-contrib-connect
  * Grunt内で起動するWebサーバープラグイン。protractorでテストするにはWebサーバが必要なので。
* grunt-shell/grunt-shell-spawn
  * シェルを実行するためのプラグイン。Selenium Serverのインストールやバックグラウンド起動のために利用する。

Gruntfileはこんな感じに記述します。

~~~js:Gruntfile.js
module.exports = function (grunt) {
    grunt.initConfig({

        // dist配下のファイルをhttp://localhost:9000/で公開する
        connect: {
            options: {
                port: 9000,
                hostname: 'localhost'
            },
            my_target: {
                options: {
                    base: 'dist'
                }
            }
        },

        // test/conf.jsの設定に従ってテストを実行
        protractor: {
            options: {
                keepAlive: true,
                noColor: false
            },
            my_target: {
                options: {
                    configFile: "test/conf.js"
                }
            }
        },
        shell: {
            options: {
                stdout: true
            },

            // Selenium Serverをバックグラウンドで実行
            selenium: {
                command: "node_modules/protractor/bin/webdriver-manager start",
                options: {
                    stdout: false,
                    async: true
                }
            },

            // Selenium Serverのインストールおよび更新
            protractor_install: {
                command: "node_modules/protractor/bin/webdriver-manager update"
            }
        }
    });
    require('matchdep').filterDev('grunt-*').forEach(grunt.loadNpmTasks);
};
~~~

# conf.js

続いてProtractorの設定ファイルを作成します。
node_modules/protractor/referenceConf.js にベースになるファイルがあるので参考にしましょう。

~~~js:conf.js
exports.config = {
    // Seleniumサーバーのアドレス
    seleniumAddress: "http://localhost:4444/wd/hub",
    // テストで利用するブラウザなどの条件を設定することができます。
    // 詳細は https://code.google.com/p/selenium/wiki/DesiredCapabilities
    capabilities: {
        browserName: "chrome"
    },
    // テスト対象のspecファイルのパス
    specs: ["spec.js"],
    // テスト対象のアプリケーションのベースURL
    baseUrl: 'http://localhost:9000/',
    // 利用するテストフレームワーク
    framework: "jasmine",
    // jasmine用の設定
    // 詳細は https://github.com/juliemr/minijasminenode
    jasmineNodeOpts: {
        showColors: true
    }
}
~~~

なお、テストフレームワークはデフォルトでjasmineを使うようになっていますが、mochaを使うことも可能です。

* https://github.com/angular/protractor/blob/master/docs/using-mocha.md

# API

Protractorでは、WebDriverJSのAPIに加えてAnuglarJSアプリのテストがしやすくなるようなAPIが用意されています。

以降では、Protractor特有のAPIを中心に解説します。

詳細はAPIリファレンスを参照してください。

* https://github.com/angular/protractor/blob/master/docs/api.md

## グローバル変数

用意されてるAPIのうち、よく利用するものはグローバル変数に登録されています。

* `by`, `By`
  * protractor.Byのエイリアス。
* `element`
  * protractor.elementのエイリアス。
  * protractor.findElementなどを駆使してうまい具合に探してくれる。
  * 内部でbrowser.waitForAngularを呼んでいるので、レンダリングのタイミングが早すぎて要素が取れないということがない。
* `browser`
  * protractor.getInstance()と同じ。WebDriverのインスタンスをラップしたもの。
* `$`
  * protractor.findElement(protractor.By.css("・・・"))のエイリアス。
* `$$`
  * protractor.findElements(protractor.By.css("・・・"))のエイリアス。


## ブラウザ操作

browserというインスタンスを使って、様々なブラウザ操作を行うことができます。
browser.get()でページを開いたり、browser.actions()でマウスイベントを発行したり、browser.wait()で処理が完了するまで待ったり、などなど。

WebDriverJSの基本的なAPIに、Protractorでは以下の機能が追加されています。

* waitForAngular
  * AngularJSのレンダリングと`$http`の呼び出しが完了するまで待つ。
* addMockModule/clearMockModules
  * モックの登録と解除を行う。
* debugger
  * アプリケーションの動作を止めてデバッガを起動する。

## ロケーター

HTMLの要素を指定するためのDSLです。
WebDriverJSでは、idやタグ名やcssセレクタで要素を指定するAPIが用意されていますが、Protractorでは以下のようなAPIが追加されています。

* by.binding
  * `by.binding('status')`で`<span>{{status}}</span>`が取得できます。適当なタグで囲ってあげないと外側の要素が取れます。
* by.repeater
  * `by.repeater('item in items')`とすると`<div ng-repeat="item in items">`の要素が取得できます。指定した行や列の要素だけを取る手段も用意されています。
* by.model
  * `ng-model`の名前で指定して、selectタグやinputタグの要素を取得できます。

## 要素の操作

element(by.binding("xxxx"))などで取得した要素に対して行う操作です。
getText()やgetAttribute()に加えて、Protractorでは以下のようなAPIが追加されています。

* all
  * ng-repeatの全要素を取得できる
* $
  * protractor.findElement(by.css(・・・))のエイリアス
* $$
  * protractor.findElements(by.css(・・・))のエイリアス
* evaluate
  * 現在の要素のscopeに対してスクリプトを実行することができます。
  * element(by.id("something")).evaluate("value")ってやると、`id='something'`要素の`$scope.value`の値がとれたりします。

結果は全てPromiseで返ってくるようになっているので、値を使いたい場合はthenでつなげて書きます。

~~~
// inputタグの値を取得
var input = element(by.model("status"));
input.getAttribute('value').then(function(attr) {
  ・・・
});

// バインドされた値の取得
var output = element(by.binding("output"));
output.getText().then(function (text) {
  ・・・
});

// ng-repeatの全値を取得
var collection = element.all(by.repeater("item in items"));
collection.then(function (rows) {
  ・・・
});

// ng-disabledの値を取得。有効なときはnullになる。
var button = element(by.id("myButton"));
button.getAttribute("disabled").then(function(disabled) {
  ・・・
});
~~~

## テストのためのAPI

ProtractorではJasmineに改良を加えており、expectの引数にPromiseを渡し、普通の値と同じように結果を比較することが可能になっています。

例えば、上記のgetText()やgetAttribute()などは全部Promiseを返すようになっていますが、`expect(element(by.binding("xxx")).getText()).toEqual("yyy")`のように書くことができます。

# テストの例

テスト対象として以下のようなHTMLを考えてみます。
inputTextに値を入れてボタンを押すと、入力した文字が大文字に変換されてdisplayTextに表示されるものと思ってください。

~~~html:sample.html
<body>
    <div ng-controller="SampleCtrl">
        <input type="text" ng-model="inputText">
        <br/>
        <div>{{displayText}}</div>
        <br/>
        <button ng-click="toUpper()">push</button>
    </div>
</body>
~~~

テストは次のように書きます。

~~~js:spec.js
describe('basics', function () {
    var input;
    var display;
    var button;

    beforeEach(function () {
        // http://localhost:9000/sample.html を開く
        browser.get('/sample.html');
        // 各要素を取得
        input = element(by.model("inputText"));
        display = element(by.binding("displayText"));
        button = element(by.tagName("button"));
    });

    it('should be to upper value', function () {
        // input要素に文字列を入力
        input.sendKeys('test');
        // ボタンをクリック
        button.click();
        // displayTextに"TEST"と表示されていることを確認
        expect(display.getText()).toEqual("TEST");
    });
});
~~~

# テストの実行

Selenium Serverを起動します。テストの実行前に必ず立ち上げておく必要があります。

~~~
grunt shell:selenium
~~~

アプリケーションを起動します。Gruntでテスト実行と同時に立ち上げるようにしておくのがおすすめです。

~~~
grunt connect:my_target:keepalive
~~~

そしてテストを実行します。

~~~
grunt protractor:my_target
~~~

ブラウザが起動してテストが実行され、次のように結果が表示されます。

~~~
Running "protractor:my_target" (protractor) task
Using the selenium server at http://localhost:4444/wd/hub
.

Finished in 7.281 seconds
1 tests, 1 assertions, 0 failures


Done, without errors.
~~~

## デバッグモード

テストが思ったとおりに動かないと思ったらデバッグモードを使いましょう。
Gruntfileのprotractorのoptions.debugをtrueにします。
（protractorの実行ファイルを直接実行している場合は、オプションに`debug`を指定します）

~~~js:Gruntfile.js
        protractor: {
            options: {
                keepAlive: true,
                noColor: false,
                debug: true
            },
            my_target: {
                options: {
                    configFile: "test/conf.js"
                }
            }
        },
~~~

`grunt protractor:my_target`を実行するとコンソールに下記のように出てきます。
`c`や`r`を入力するとテストの実行が開始されます。

~~~
< debugger listening on port 5858
connecting... ok
break in node_modules/protractor/lib/cli.js:8
  6  */
  7 
  8 var util = require('util');
  9 var path = require('path')
 10 var fs = require('fs');
debug> 
~~~

テストコード中に`browser.debugger();`と書いておくと、テストを実行した時にそこで止まってくれます。

replを起動すると、JavaScriptのコードを書いて変数の中身を確認できたり、とても便利です。
その他ステップ実行などいくつかのコマンドが用意されています。デバッガの中で`help`と打ってみましょう。

# おわりに

個人的には、小さいプロダクトを書く時はユニットテストはあまり効果を感じられなくて、書くのも面倒に感じてしまいます。
でもE2Eテストなら手間もそんなにかからないし、ドキュメント代わりにもなるのでおすすめです。