おっさんエンジニアの備忘録

ここは主におっさんエンジニアが技術的な事を調べたり、試してみたりしたことを備忘録的に書いていくものです。忘れっぽいからね。誰かの参考になったら嬉しい。

generator-angular環境に手を加えて、protractorでE2Eテストを動かしてみる。

e2eテストってカッコ良いですよね

AngularJSの公式チュートリアルにはE2Eテストのチュートリアルも含まれています。
これを動かすと、ブラウザが立ち上がって、文字入力や画面遷移が自動的に実行され、テストされていきます。
個人的にはユニットテストだけでテストを担保するのに若干の不安がありましたので、ここまで外のレイヤーでのテストが継続的に行われていると安心できるかなぁと思っています。(もっともテストを書く手間が大変そうではありますが・・・)

是非ともこのテストも標準で行っていきたいのですが、generator-angularでは標準で動かす準備がないようです。
ng-scenarioを使ったやりかけの部分はあるようですが、AngularJSの公式では「ng-scenarioじゃなくてprotractor使いなよ」って言われていますので、そちらを使えるようにいろいろ調べて行きたいと思います。

protractorを動かすためのサンプルページを作る

今回はprotractorを動かすだけなので簡単なページです。
yo angularでできたアプリを簡単に改造しました。
なおデータの取得はソースベタになっていますのであしからず。

<div class="container" ng-controller="MainCtrl">
  <div class="row">
    <div class="col-xs-3">
      <form class="well" role="form">
        <div class="form-group">
          <label for="searchText">Test Search</label>
          <input id="searchText" type="text" class="form-control" ng-model="query.$" placeholder="input search text">
        </div>
        <div class="form-group">
          <label for="sortKey">Sort Key</label>
          <select id="sortKey" class="form-control" ng-model="sort.key">
            <option value="id">Default</option>
            <option value="title">Title</option>
          </select>
        </div>
        <div class="form-group">
          <label for="sortOrder">Sort Revers</label>
          <input id="sortOrder" type="checkbox" ng-model="sort.revers"></input>
        </div>
      </form>
    </div>
    <div class="col-xs-9" class="items-panel">
      <div class="panel panel-default" ng-repeat="e in items | filter:query | orderBy:sort.key:sort.revers">
        <div class="panel-heading">
          <div class="item-title" ng-bind="e.title"></div>
        </div>
        <div class="panel-body">
          <div class="item-description" ng-bind="e.description"></div>
        </div>
      </div>
    </div>
  </div>
</div>

以下はJSのコード。

'use strict';

angular.module('testApp')
  .controller('MainCtrl', function ($scope) {
    $scope.items = [
      {
        id:1,
        title:'HTML5',
        description: '説明文は省略'
      },
      {
        id:2,
        title:'AngularJS',
        description: '説明文は省略'
      }
    ];

    $scope.query = {
      $: ''
    };
    $scope.sort = {
      key:'id',
      revers:false
    };
  });

とりあえず、サンプルで動かすテストコードです。
AngularJSのサンプルから拾ってきたものです。(画面の挙動とは一切関係ありませんね・・・)

describe('angularjs homepage', function() {
  it('should greet the named user', function() {
    browser.get('http://www.angularjs.org');

    element(by.model('yourName')).sendKeys('Julie');

    var greeting = element(by.binding('yourName'));

    expect(greeting.getText()).toEqual('Hello Julie!');
  });
});

protractorを動かす

Gruntprotcatorを動かすモジュールを探したら、grunt-protractor-runnerなるものがヒットしたので、そちらを使ってみます。

https://github.com/teerapap/grunt-protractor-runner

インストールはいつも通りコマンド一発。

$ npm install grunt-protractor-runner --save-dev

Gruntfileにタスクを追記します。

protractor: {
  options: {
    configFile: "node_modules/protractor/referenceConf.js",
    keepAlive: true,
    noColor: false,
    args: {
    }
  },
  sample: {
    options: {
      configFile: "e2e.conf.js",
      args: {
        browser: 'chrome'
      }
    }
  },
},
// 中略
grunt.registerTask('e2e', function(){
  grunt.task.run([
    'clean:server',
    'concurrent:test',
    'autoprefixer',
    'connect:test',
    'protractor'
  ]);
});

protractorの設定ファイルは以下のとおりです。

exports.config = {
  allScriptsTimeout: 11000,

  specs: [
    'test/e2e/{,*/}*.js'
  ],

  capabilities: {
    // 'browserName': 'chrome'
    // 'browserName': 'firefox'
    // 'browserName': 'safari'
  },

  chromeOnly: false,

  baseUrl: 'http://localhost:9001/',

  framework: 'jasmine',

  jasmineNodeOpts: {
    defaultTimeoutInterval: 30000
  }
};

※注意!
chromeOnlyfalseに設定している場合、WebDriverを使うことになりますが、その場合はJDKのインストールが必須になります。事前に入れておいてください。
あと、立ち上げるブラウザの種類ですが、Gruntfileで指定する場合はbrowserでe2e.conf.jsで指定する場合はbrowserNameになります。(キーが違ってハマった・・・)
指定箇所を明示するためにあえてコメントで残してあります。

さて、準備万端、意気揚々とコマンド実行です。

$ grunt e2e

が、ダメ!動かない・・・
どうやらprotractorに同梱のwebdriver-managerを走らせる必要がありそうです。いろいろ調べたのですが、どうもgrunt-protractor-runnerは自身のnode_modulesディレクトリ配下のprotractorを使っているらしく、そちらのwebdriver-managerを実行してねって書いてありました。で、タスクは?うん、ないですね・・・えぇ〜!?
ちなみに変更履歴に以下のような記述がありました。

Temporarily remove automatically download/update webdriver-manager because it fails in some environment such as Windows (#41)

諦めんなよ!
しかたがないので、手実行、と言いたいところですが、これだと環境構築でめんどくさいことになりそうなので別の方法を探してみますが・・・見つからないです・・・
※途中grunt-protractor-webdriverというプラグインが見つかったのですが、これは違った・・・

幸い、protractorが個別にインストールされている場合はそちらを参照してくれるような作りになっているぽいので、何とかそちらで管理をしてみようと思います。
※グローバルインストールも考えたのですが、環境が個別にまとまっている感じが良かったので・・・

少々強引ではありますがgrunt-shellを使ってテスト前にwebdriver-managerのupdateを走らせることにします。
まずは必要なプラグインをインストール。

$ npm install protractor --save-dev
$ npm install grunt-shell --save-dev

続いてコマンドを登録します。

shell: {
  webdriver_update: {
    command: 'node_modules/.bin/webdriver-manager update'
  }
}

最後にタスクを修正します。

grunt.registerTask('e2e', function(){
  grunt.task.run([
    'clean:server',
    'concurrent:test',
    'autoprefixer',
    'connect:test',
    'shell:webdriver_update',
    'protractor'
  ]);
});

いざ実行。

$ grunt e2e

できた!けどイケてねぇ〜!
とりあえず作者の今後の活動に期待しときます・・・
※ここまで書いてnpmのpostinstallもアリかなぁとちょっと思いました・・・

実際のテストを書いてみる。

実際にサンプルに対してE2Eテストを書いてみました。

'use strict';

describe('sample e2e tests.', function() {

  var searchText,sortKey,sortRevers,itemsPanel;

  beforeEach(function() {
    browser.get('/index.html');
    searchText = $('#searchText');
    sortKey = $('#sortKey');
    sortRevers = $('#sortOrder');
    itemsPanel = $('.items-panel');
  });

  it('default search setting.', function(){
    expect(searchText.getText()).toBe('');
    expect(sortKey.getAttribute('value')).toBe('id');
    expect(sortRevers.isSelected()).toBeFalsy();

    itemsPanel.$$('.item-title').then(function(items){
      expect(items.length).toBe(2);
      expect(items[0].getText()).toBe('HTML5');
      expect(items[1].getText()).toBe('AngularJS');
    });
  });

  it('search "ang" result.', function(){

    searchText.sendKeys('ang');

    itemsPanel.$$('.item-title').then(function(items){
      expect(items.length).toBe(1);
      expect(items[0].getText()).toBe('AngularJS');
    });
  });

  it('search sort by title.', function(){

    $('#sortKey > option[value=title]').click();

    itemsPanel.$$('.item-title').then(function(items){
      expect(items.length).toBe(2);
      expect(items[0].getText()).toBe('AngularJS');
      expect(items[1].getText()).toBe('HTML5');
    });

  });

  it('search sort desc.', function(){

    sortRevers.click();

    itemsPanel.$$('.item-title').then(function(items){
      expect(items.length).toBe(2);
      expect(items[0].getText()).toBe('AngularJS');
      expect(items[1].getText()).toBe('HTML5');
    });
  });
});

今回はあえてAngularJSに特化しない形でテストケースを書いています。
具体的には、DOMの検証にはCSSセレクターのみを利用し、その要素に対してイベントを発行している、といった具合です。

基本的な書き方は恐らくJasmineに準拠するのですが、protractor用に幾つか便利なメソッドが提供されています。詳しい使い方は、公式のマニュアルが詳しいのでそちらを参照にすると良いです。

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

以下、トピックを抜粋して紹介します。

ElementFinder.prototype.$

jQuery使えるのかな?と思いきや、実は全く違います。ElementFinderというオブジェクトをCSSセレクターで返すエイリアスの関数です。
ElementFinderは単一のDOMオブジェクトというイメージです。複数のオブジェクトを同一視することはなく、複数オブジェクトを取り扱う場合はElementArrayFinderというオブジェクトを使います。(ちょっと不便な気がしますね・・・今後改善されるのでしょうか?)
余程のことがなければjQueryライクに書けるとは思いますが、より厳密に書いてあげたほうが良さそうですね。

ElementFinder.prototype.$$

こちらはElementArrayFinderを返します。上述の通り、こちらは複数のオブジェクトというイメージですね。
複数のDOMが帰ってきますので、実際の値を取得するgetやらcountやらが用意されています。getで帰ってくるのはElementFinderになります。

ここで非常に注意したいのが、ElementFinderElementArrayFinderから値を取得するメソッドとして提供されているgetTextcountといったメソッドの戻り値はPromiseになっているということです。
Promiseの詳細については割愛しますが、ざっくり言うと、「非同期通信の結果を取得するためのエンドポイント」ということです。(Java でいうところのFutureみたいなものでしょうか)
Promiseを通じて取得される値は非同期で順序が保証されていませんので、今回のテストのようにthenなどを使って、コールバック経由でないと実際の値は取得できません。したがって、以下の様な書き方はできません。

if ($('#hogehoge').getText() == 'inputValue') {
  // logic
}

代わりに以下のように書きます。

$('#hogehoge').getText().then(function(text){
  if (text == 'inputValue') {
    // logic
  }
});

もうこうなってくるとわけわからなくなります。
コールバックの呼び出しより先に後続ロジックが動いてしまいますので、処理をシリアルに実行しようとするとthenで返ってくるPromiseを更にチェインして処理を書いて・・・なんて事になります。実際に、セレクトボックスの値を取得しようとループで回して選択値を取得して検証、なんてテストを書こうとしてえらく大変でした。なので、DOMの取得は極力CSSセレクタで一発で取れるよう記述しないと話になりませんね・・・

「値の比較ができないってことはexpectはちゃんと動くの?」と思ったのですが、こちらは正常に動作しているようです。中身までは確認していませんが、expectによる検証は内部的にPromiseを解釈して値検証をしていると思われます。expect自体はそれぞれが独立した検証であり、検証順序の厳密性は問われないからでしょう。なるほど、頭良いですね。

恐らく、WebDriverとの値のやり取りはプロセス間通信が発生するため、即時に値を取得してロジックに反映させることができないのではないかと推測しています。まぁこればかりは仕方ないですね・・・

webdriver.WebElement.prototype.click

DOM要素をクリックする処理です。「テストコードではElementFinderclickを呼んでいるけど大丈夫?」と思ったのですが、こちらは大丈夫なようです。

The ElementFinder can be treated as a WebElement for most purposes, in particular, you may perform actions (i.e. click, getText) on them as you would a WebElement.

webdriver.WebElement.prototype.sendKeys

こちらはキー入力を行うものです。
基本的な使い方は上述のclickと同じです。

まとめ

今回はあえてAngularJSに依存しないE2Eテストを書いてみましたが、AngularJSを使う場合はby.modelなどAnuglarJSの変数に直接アクセスできるようなコードも書けるみたいですので、よりテストはし易いかなぁと感じました。
しかしながら、今回のようにCSSセレクターを利用したDOMの取得と、クリックとキー入力というイベントの発火を行うだけであれば、非常に汎用的ですし、AngularJSを使っていないアプリに対してもテストが書けます。protractorはこの辺のサポート実装を行っていますのでテストのコードの記述量を抑える意味でも導入するメリットは大いにあります。

また、今回は試していませんが、distで本番用にコンパイルしたコードをベースにしたテストなどもできそうです。変なミスをなくす上では重要なアプローチですよね。
テスト自体が自動化できたということでJenkinsなどのCIツールと組み合わせたりすると、テストが捗りそうですので、機会があればその辺も調査していきたいと思います。
ただ、実際のアプリとなると、連携させるAPIの準備であったり、APIが返すデータをDBなどに初期投入するなど、まだまだやることは多そうです。今後の課題ですね。

とりあえず、開発するにあたって必要な環境構築はひと通りできたかなぁと思います。これでやっとAngularJSを使ったアプリ開発に入れますね。(長かった・・・)

今後は、実際のAnuglarJSを触りながら、気になったトピックなどを書いていきたいですね。(まぁあくまで備忘録なのであしからず)