is Neet

ネトゲしながら暮したい

angularjsのunitテストの書き方【$resource編】

さて前回の記事、「Rails4.0でangularjsを使ってRESTfulなajaxを実装する」に引き継ぎ今回はangularjsのテストについて書こうと思う。
題材には引き続き前回の内容を使うので、そちらを読んでからのほうが読みやすいかと。
ちなみにangularjsにしてもテストにしても自分自身かなり手探りで進めているため、不自然な点や改善点があればどんどん伺いたい。

実行環境

angularjsはtestableだと謳われている割にビルドインされたテスト実行環境がないので自力で整える必要がある。(しょうが無い気もする) 今回はテスト実行環境にkarma、テストフレームワークはjasmineを使用する。
前提としてnodeはインストールされているものとする。

$ npm install -g karma
$ npm install -g karma-ng-scenario

以上でkarmaがインストールされた。
次に設定ファイルだ。

まずはspec/以下にjavascriptsというディレクトリを作ろう。

$ mkdir spec/javascripts

次に設定ファイルの編集だ。

$ vim spec/javascripts/karma.conf.js
module.exports = function(config) {
  config.set({
    basePath: '../..',

    frameworks: ['jasmine'],

    autoWatch: true,

    preprocessors: {
      '**/*.coffee': 'coffee'
    },

    files: [
      'app/assets/javascripts/jquery.js',
      'app/assets/javascripts/angular/angular.js',
      'app/assets/javascripts/angular/angular-mocks.js',
      'app/assets/javascripts/angular/*',
      'app/assets/javascripts/admin/widgets.js'
      'spec/javascripts/**/*_spec.js.coffee'
    ],
    exclude: [
      'app/assets/javascripts/angular/angular-scenario.js'
    ]
  });
};

さていくつか設定ファイルの段階で注意しておかないといけないことがある。
まず、filesというoptionに渡すjsファイルの置き場所についてだ。
railsサーバがローカルで走っている状態であれば

files: [
  ...
   'http://localhost:3000/assets/angular/angular.js'
  ...
]

上記のような指定でも構わない。
ただ、テストを実行させる上で毎回必要になるmoduleが増える度に設定ファイルにincludeするのが面倒だし、テストの実行環境がいつでも3000番portだとは限らない。
それでもangularjsやjQueryだけの問題であればgoogle CDNなどを指定してもいいのかもしれないけれど、CDNで管理されていないpluginなどもincludeする必要が出てきた時にそれらだけfile path指定になるのも違和感があるので、面倒だけどassets以下で管理することにして話を進める。
取り敢えずテストを実行したいのだ。こまけぇこたぁいいんだよ!
なので面倒ではあるが、assets以下のそれっぽいディレクトリにangularjsや必要になるプラギンが纏まってる前提で話を進める。

さてその上で次に問題になるのが、'app/assets/javascripts/angular/*',でangularjs関連の全てのファイルをincludeしてしまうとファイルの読み込み順と、テストに不必要なファイルまで読み込んでしまうことだ。
前者にはangular.jsangular-mocks.jsを先に指定して読み込むことで対応し、後者にはexclude optionにangular-scenario.jsを指定することで解決する。

あとテストはどうしてもその構造上ネストが深くなり可読性が下がる傾向にあるので、怠惰を極めたSyntaxのcoffeeの方が相性が良いと思うのでcoffeeで進める。
長々と話したが実行環境を整えるSTEPはそんなに大変ではない。

実行してみる

一度前回のエントリーで紹介したサンプルスクリプトの内容を見てみよう。

// ./app/assets/javascripts/widgets.js
var sampleApp = angular.module('sampleApp', ['ngResource']);

// csrf tokenの設定
sampleApp.config(function($httpProvider){
    $httpProvider.defaults.headers.common['X-CSRF-Token'] =
        $('meta[name=csrf-token]').attr('content');
});

sampleApp.factory('Widget', function($resource){
    var Widget = $resource('/widgets/:id.json', {id: '@id'});
    return Widget;
});

sampleApp.controller('WidgetsController', function($scope, Widget){
    $scope.widgets = Widget.query();
})

ではまず、最初に全ての基盤となるsampleAppモジュールが存在することを確認するテストを書いておこう。
ディレクトリ名やファイル名は適当でいい。

# spec/javascripts/module/sample_app_spec.js.coffee

describe "sampleApp module", ->
  sampleApp = null

  beforeEach ->
    sampleApp = angular.module('sampleApp')

  it '存在すること', ->
    expect(sampleApp).not.toBeNull()

実行してみよう。

$ karma start spec/javascripts/karma.conf.js
INFO [karma]: Karma v0.10.9 server started at http://localhost:9876/
INFO [Chrome 33.0.1750 (Mac OS X 10.8.5)]: Connected on socket v38eh7FqGBdYR2QSEY0x

karmaが起動するとhttp://localhost:9876/にアクセス出来るはずなので、一度ブラウザでこのURLを開いておくとそれ以降はファイルの変更を自動検知してテストが走ってくれる。

Chrome 33.0.1750 (Mac OS X 10.8.5): Executed 1 of 1 SUCCESS (0.374 secs / 0.078 secs)

上記のようなメッセージが出れば成功だ。
次に、sampleApp moduleは、ngResourceに依存することを宣言時に指定しているのでコレもテストしておこう。

# spec/javascripts/module/sample_app_spec.js.coffee

it '依存moduleの確認', ->
  expect(sampleApp.requires).toContain('ngResource')

ngResource以外にも今後増えていくならこのit句に、expect(sampleApp.requires).toContain('ngSanitize')のような形で増やしていけばいい。

angularjsのunitテストは何をテストするモノなのか

angularjsでajaxのテストを書く場合のポイントをまずは知っておこう。

  • 実際のリクエストはmock化する。
  • viewの見た目の変化に関してはdirective(と$scope)の仕事としてロジックと完全に切り離すことで、testableになっている。

上記を踏まえた上で今回テストしたいことを考えてみる。

f:id:soplana:20140301230108p:plain

今回のサンプルアプリケーションは、「機能を使う」「機能を削除する」のボタンが押される度にサーバにリクエストが飛び、データを更新してボタンがtoggleするというシンプルなものだ。
ではそのボタンのtoggleはどのように実装されていたか。

%button.btn.btn-info{'ng-click'=>'widget.$save()', 'ng-hide'=>'widget.active'}
  この機能を使う
%button.btn.btn-warning{'ng-click'=>'widget.$remove()', 'ng-show'=>'widget.active'}
  この機能を削除する

viewにかかれてある'ng-hide'=>'widget.active''ng-show'=>'widget.active'によってtoggleは実装されている。
widgetが使われていない時、widget.activeはfalseとなり、widgetが使われている時に、widget.activeがtrueになる。
これを利用してボタンのshowとhideを制御することで実現されている。

この場合、ボタンの表示非表示を制御しているのはng-hideng-showのdirectiveだ。
つまり事実これらの要素の表示非表示切り替えがうまくいくかどうかに関しては我々がテストで担保する所ではない。
angularjsを信じるしかないのだ。大丈夫、信じよう。

こうしてdirective(と$scope)とロジックを切り離して処理が記述出来る事により、我々がテストで担保しないといけない部分は単純に「リクエストの結果、widget.activeのtrue/falseが切り替わるかどうか」に集中すればよい。
素晴らしい。
仮にココに「通信中はボタンがdisabledになること」を証明したいケースが追加されたとしても、ng-disabledというdirectiveが 存在するので考え方としては同じでいい。

テストを書いてみよう!

それでは実際にテストの内容をみてみよう。

# spec/javascripts/resource/widget_spec.js.coffee

describe "widget", ->
  Widget = null

  beforeEach module('sampleApp')
  beforeEach inject ($injector)->
    Widget = $injector.get('Widget')

  it 'widgetモデルが存在する', ->
    expect(Widget).not.toBeNull()

まずはWidgetが存在することを確認。unitテストではinjectを使って、依存性の注入を行う(多分)。
次にいよいよモックを使ったテストを追記していく。

# spec/javascripts/resource/widget_spec.js.coffee

  describe 'widgetのon/off', ->
    url = '/admin/widgets/blog.json'
    blog = null

    describe '機能を有効にする', ->
      response = {id: 1, _type: 'blog', active: true}

      beforeEach inject (_$httpBackend_)->
        _$httpBackend_.expectPOST(url).respond(response)
        
        blog = new Widget(_type: 'blog', active: false)
        blog.$save()
        _$httpBackend_.flush()

      it 'activeになること', ->
        expect(blog.active).toBeTruthy()

基本的にテストを書く上ではbeforeには「振る舞い」を、itには「テスト内容”のみ”」を記述するべきだと考えているので極力その理念にしたがって書いていく。
この例ではitでblogと名づけたwidgetのactiveプロパティの変化をテストしている。まぁそこは読めばわかると思う。
ポイントとなるのはbeforeで行っているモックの宣言周りだと思う。

では_$httpBackend_とは何なのか。
これはhttp requestをモック化してくれたり、それに対応するresponseを設定しておくことが出来るmoduleだ。
_$httpBackend_を使うときに大まかな流れは、

  • _$httpBackend_にリクエスト先とレスポンスを設定する
  • リクエストが実際に飛ぶアクションを実行する(この時点ではリクエストは発生しない)
  • _$httpBackend_.flush()を呼びだし、リクエストを実行する

になる。
なのでまずはexpectPOSTexpectGETの引数にURLを指定し、.respondにresponseデータを設定しておく。
次にblog = new Widget(_type: 'blog', active: false)widgetを、active = falseの状態で生成しておく。
次にblog.$save()で、本来なであればリクエストが実際に飛ぶ処理を呼び出す。
そして最後に_$httpBackend_.flush()を呼び出すことによって、リクエストが発行されresponseが返る。

これらのbeforeが処理された後、itの中でactiveプロパティがtrueに変更されていることをテストする。

$removeで発行されるDELETEの処理も同様に書ける。

DELETEのテストも追加してみよう

    describe '機能を無効にする', ->
      response = {id: 1, _type: 'blog', active: false}

      beforeEach inject (_$httpBackend_)->
        _$httpBackend_.expectDELETE(url).respond(response)
        
        blog = new Widget(_type: 'blog', active: true)
        blog.$remove()
        _$httpBackend_.flush()

      it 'non activeになること', ->
        expect(blog.active).toBeFalsy()

はい。大差ないですね。

次回は

今回は$resourceのテストの書き方を紹介した。
controllerのテストはまた少し違ったポイントがあるのでまた次回紹介したい。
あとはdirectiveとかserviceとかに関してもまだあんまり触れられてないのでその辺も書いていきたい。