Intelligent Technology's Technical Blog

株式会社インテリジェントテクノロジーの技術情報ブログです。

AngularJSの単体テストツール - Karma

こんにちは、間藤です。

巷ではGoogle製MVC(MVW)フレームワークのAngularJSが流行っているようですが、残念ながらこれまでのところ業務で利用したことはありません。どんなものかくらいは押さえておこうと数か月前に公式サイトのチュートリアルをさらってみましたが、これがなんともよく出来ていて一人で関心していました。AngularJSチームは、テストにも非常に力を入れていて、ProtractorKarmaといったテストツール(両方ともNode.jsのパッケージです)を開発しています。Protractorは、seleniumを利用してブラウザの自動テストをJasmineで書くことができます。(Mochaなど、別のテストフレームワークも利用できますが、デフォルトはJasmineだそうです。)

で、今回はKarmaです。
KarmaとJasmineの関係性を整理しながら話をすすめられたらと思っています。Jasmineそのものの説明はしませんので、Jasmineを利用されたことがない方は、公式サイト等をチェックしていただければと思います。

Jasmine

Karmaの前にJasmine単独でテストする場合を見てみます。

Protractorもそうですが、KarmaもJasmineでテストを書きます。MochaやQUnitでもいけますが、今回はJasmineを単独で利用したときと、Karmaを利用したときの違いに着目していこうと思います。
ところで、JavaScriptのテストフレームワークでは、今挙げた「Jasmine」「Mocha」「QUnit」の3つが人気だそうです。(参考記事
その中でもJasmineが頭一つ抜けている感じでしょうか。そういえば、PhoneGapも、phonegapコマンドで自動生成されるプロジェクトにJasmineのテストランナーが含まれるようになっていました。ただ、私は、これでテストを書いたことがありませんが。。。

では、Jasmineのテストを動かしていきます。
今回は、Jasmineのgithubリポジトリをcloneして、その中に含まれているサンプルのテストを実行してみます。

> git clone https://github.com/pivotal/jasmine.git

cloneしたプロジェクト内(dist)には、各バージョンの実行環境がzip形式で配置されているので、これを使います。今回は、jasmine-standalone-2.0.1.zipを使います。本来なら、このzipをテスト対象のアプリケーション配下にコピー、展開して利用しますが、今回は単にこのzipに含まれているサンプルのテストを実行します。

適当なパスにjasmine-standalone-2.0.1.zipをコピー、展開します。

│  MIT.LICENSE
│  SpecRunner.html
│
├─lib
│  └─jasmine-2.0.1
│          boot.js
│          console.js
│          jasmine-html.js
│          jasmine.css
│          jasmine.js
│          jasmine_favicon.png
│
├─spec
│      PlayerSpec.js
│      SpecHelper.js
│
└─src
        Player.js
        Song.js

src配下がテスト対象のスクリプト、spec配下がJasmineで記述されたテストスクリプトです。テストを実行するには、SpecRunner.htmlをブラウザで開きます。下の例は、Chromeでテストを実行した結果です。

f:id:IntelligentTechnology:20140801105706p:plain

テスト対象もテストスクリプトも自分で書いたものではないので、テストを実行した実感はまったく湧かないですが、これでテストは実行されています。IEやFirefoxといった他のブラウザでもこのテストが成功することを確認したいなら、それぞれのブラウザでテストランナーを開く必要があります。

次にこのテストをKarmaで実行する手順を見ていきます。

Karma

冒頭にも書いたようにKarmaはNode.jsのパッケージですので、Node.jsがインストールされていることが前提です。

まず、KarmaのCLIをグローバルインストールします。Karma本体をグローバルインストールして利用することもできますが、公式サイトを見る限り、Karma本体はプロジェクトごとに個別インストールすることを想定しているようです。

> npm install -g karma-cli

先ほど展開して作成されたディレクトリに移動して、Karmaをインストールします。(package.jsonを作るなら、--save-devオプションをつけます。)

> cd jasmine-standalone-2.0.1
> npm install karma

なお、今回インストールしたKarmaのバージョンは、0.12.19でした。

> karma --version
Karma version: 0.12.19

テストの実行にテストランナー(SpecRunner.html)は不要なので削除しておきます。(残しておいても構いません。)

> rm SpecRunner.html

次にKarmaの設定ファイルを生成します。コマンドを実行すると、入力を促されるので、以下のように入力していきます。これらが設定ファイル(karma.conf.js)に反映されますので、karma.conf.jsを後で編集して、設定を変更することも可能です。

> karma init

Which testing framework do you want to use ?                                                         
Press tab to list possible options. Enter to move to the next question.                              
> jasmine                                            ※デフォルトのまま
                                                                                                     
Do you want to use Require.js ?                                                                      
This will add Require.js plugin.                                                                     
Press tab to list possible options. Enter to move to the next question.                              
> no                                                 ※デフォルトのまま
                                                                                                     
Do you want to capture any browsers automatically ?                                                  
Press tab to list possible options. Enter empty string to move to the next question.                 
> Chrome                                             ※テストを実行するブラウザ(Tabキーで選択)
> Firefox                                                                                            
> IE                                                                                                 
> PhantomJS                                                                                          
>                                                                                                    
                                                                                                     
What is the location of your source and test files ?                                                 
You can use glob patterns, eg. "js/*.js" or "test/**/*Spec.js".                                      
Enter empty string to move to the next question.                                                     
> src/*.js                                           ※テスト対象
> spec/*.js                                          ※テストスクリプト
>                                                                                                    
                                                                                                     
Should any of the files included by the previous patterns be excluded ?                              
You can use glob patterns, eg. "**/*.swp".                                                           
Enter empty string to move to the next question.                                                     
>                                                                                                    
                                                                                                     
Do you want Karma to watch all the files and run the tests on change ?                               
Press tab to list possible options.                                                                  
> yes                                                                                                

※お使いのマシンにChrome、Firefox、IEがインストールされていることを前提としています。

この段階でnode_modules配下を見てみると、以下のようになっているはずです。

└─node_modules
    ├─karma
    ├─karma-jasmine
    ├─karma-chrome-launcher
    ├─karma-firefox-launcher
    ├─karma-ie-launcher
    └─karma-phantomjs-launcher

karmaは、先ほど手動でインストールしました。それ以外は、initコマンドで選択した結果インストールされたものです。これらを「プラグイン」と呼びます。設定ファイルを手動で編集した場合、これらプラグインは自動でインストールされませんから、編集内容に合わせて自前でインストールしなければなりません。

今回は、Jasmineの2系でテストをしたいのですが、上の手順でインストールされたkarma-jasmineは1.3に対応しています。そこで、以下のようにして、手動で2系に対応したkarma-jasmineをインストールします。
※これは今後のリリースで変更されるでしょうから、いずれこの情報も古くなってしまうでしょう。

> npm install karma-jasmine@2_0

これでテストが実行できるようになったので、以下のコマンドでテストを起動します。

$ karma start                                  

そうすると、指定しておいたブラウザが起動して、それぞれでJasmineのテストが実行されます。テストランナーを利用したときとは異なり、テストがパスしたかはブラウザ上には表示されないようです。(表示されるようにカスタマイズすることも可能なのかもしれませんが、確認していません)
テスト結果は、コンソールのほうで確認できます。

INFO [karma]: Karma v0.12.19 server started at http://localhost:9876/                            
INFO [launcher]: Starting browser Chrome                                                         
INFO [launcher]: Starting browser Firefox                                                        
INFO [launcher]: Starting browser IE                                                             
INFO [launcher]: Starting browser PhantomJS                                                      
INFO [IE 11.0.0 (Windows 8.1)]: Connected on socket _MhMWN2PEsmPj42LghQx with id 60783407        
INFO [Chrome 36.0.1985 (Windows 8.1)]: Connected on socket vTBMuHMtiMa3T08eghQy with id 63019469 
INFO [PhantomJS 1.9.7 (Windows 8)]: Connected on socket 5BOasUOJlcfry0T9ghQw with id 42608630    
INFO [Firefox 31.0.0 (Windows 8.1)]: Connected on socket v0dPISc-Ikt4zAgjghQz with id 8432956    
PhantomJS 1.9.7 (Windows 8): Executed 5 of 5 SUCCESS (0.003 secs / 0.004 secs)                   
Firefox 31.0.0 (Windows 8.1): Executed 5 of 5 SUCCESS (0.004 secs / 0.004 secs)                  
IE 11.0.0 (Windows 8.1): Executed 5 of 5 SUCCESS (0.006 secs / 0.002 secs)                       
Chrome 36.0.1985 (Windows 8.1): Executed 5 of 5 SUCCESS (0.009 secs / 0.005 secs)                
TOTAL: 20 SUCCESS                                                                                

デフォルトの設定では、Karmaはテスト対象やテストスクリプトの変更を監視するようになっています。ですから、例えば以下のようにテストを追加して、スクリプトを保存すると、自動的にテストが再実行されます。(Socket.ioを利用しているようです)

it("should be able to play a Song", function() {
  player.play(song);
  expect(player.isPlaying).toBeTruthy();
});

結果はコンソールで確認できます。

INFO [watcher]: Changed file "c:/Users/matoh/Documents/jasmine-standalone-2.0.1/spec/PlayerSpec.js".
PhantomJS 1.9.7 (Windows 8): Executed 6 of 6 SUCCESS (0.005 secs / 0.007 secs)
IE 11.0.0 (Windows 8.1): Executed 6 of 6 SUCCESS (0.024 secs / 0.005 secs)
Chrome 36.0.1985 (Windows 8.1): Executed 6 of 6 SUCCESS (0.308 secs / 0.005 secs)
Firefox 31.0.0 (Windows 8.1): Executed 6 of 6 SUCCESS (0.007 secs / 0.003 secs)
TOTAL: 24 SUCCESS

開発中には便利かもしれません。ただ、例えばJenkinsからテストを実行する場合などには、これでは困りますので、そういったときには以下のようにオプションを指定します。

> karma start --single-run

あるいは、karma.conf.jsのsingleRunエントリの設定を変更します。

singleRunを指定すると、テスト実行後にブラウザは閉じますが、指定しなければ起動したままとなります。

f:id:IntelligentTechnology:20140801132850p:plain

右上のDEBUGボタンを押すと、別のウィンドウが開いて、このウィンドウでJasmineのテストを実行することができますので、問題が起きたときはなどは、デベロッパーツールを利用して問題箇所を探すことが可能です。

f:id:IntelligentTechnology:20140801133310p:plain

ここまでの内容を整理すると、Karmaを利用すると以下のようなメリットが得られることがわかりました。

  • 各種ブラウザでのテスト実行が自動化される
  • ファイル編集に連動してテストを自動的に再実行することが可能(開発時に便利)
  • CIツールとの連携も容易になる(上で試しているわけではないですが)

AngularJSでKarma(というかJasmine)

Jasmine付属のサンプルテストが実行できたことからもわかるように、KarmaはAngularJSのアプリケーションでなくても利用できます。*1

とは言っても、AugularJSチームが作成したツールですから、AngularJSで作成されたアプリケーションの単体テストにどう適用されているかを見てみたいと思います。ただ、以下では、Karmaというよりも、AngularJSの単体テストをJasmineでどう記述するかという観点で見ていきますから、Karmaに関して得られるものは少ないかもしれません。

例によって、GitHubからサンプルアプリケーション(チュートリアル)を取得して、この中に含まれている単体テストを覗いてみようと思います。(今回は基本的に他力本願です)

> git clone --depth=14 https://github.com/angular/angular-phonecat.git

環境をセットアップします。

> cd angular-phonecat
> npm install

これでKarma含め必要なものが一揃いします。なお、AngularJSは、Twitter社製のパッケージマネージャであるbowerを利用して、app/bower_components配下にインストールされます。bowerのインストール自体は、npmがやってくれますし、bowerコマンドの実行もpackage.jsonのpostinstallに指定されてますので、上記のコマンド一発で全部揃うようになっています。本当によく出来てます。
Karmaのテストを実行するには、以下のようにします。

> npm test

これだけで大丈夫なのは、package.jsonに設定されているからです。

  "test": "node node_modules/karma/bin/karma start test/karma.conf.js",

設定ファイルを変更せずにsingleRunにしたいなら、karmaコマンドを使います。

> karma start test/karma.conf.js --single-run

Karmaに関しては、これだけなのですが、今回はテストスクリプトの中身も確認してみます。
Karmaのテストは、test/unit配下にあります。4ファイルありますが、テストらしいテストが書かれているのは、コントローラに対するテストくらいです。このサンプルアプリケーションは、以下の2つの画面があり、それぞれにコントローラ(PhoneListCtrl、PhoneDetailCtrl)が用意されます。実装は、app/js/controllers.jsになります。

【一覧画面】
f:id:IntelligentTechnology:20140806101703p:plain

【詳細画面】
f:id:IntelligentTechnology:20140806101948p:plain

これらコントローラがやっていることはいたってシンプルで、サーバサイドから表示する内容が記載されたJSONファイルを取得して、$scope変数のプロパティに結果を設定することです。ビューを構成するテンプレートから$scope変数に設定された内容を参照することで、画面表示を行っています。コントローラがDOMの操作を直接行わないことにより、コントローラの単体テストを容易にしています。
コントローラがサーバサイドからJSONを取得する部分は、サービスに依存するようになっています。単体テストを行う上では、このJSONデータ取得処理をモック化する必要があります。これを実現するための機能をAngularJSは提供してくれます。この場面では、サーバへのリクエストをモック化したいわけですが、それはサービスの背後で機能している$httpBackendというサービスに対して、期待する結果を設定することで実現しています。

beforeEach(inject(function(_$httpBackend_, $rootScope, $controller) {
  $httpBackend = _$httpBackend_;
  $httpBackend.expectGET('phones/phones.json').
      respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]);
  scope = $rootScope.$new();
  ctrl = $controller('PhoneListCtrl', {$scope: scope});
}));

AngularJSは、DI(Dependency Injection)コンテナの機能も提供してくれるので、利用したいサービスを簡単に取得できます。上の例では、$httpBackendサービスを取得するためにinject関数を利用しています。J2EEのフレークワークなどではアノテーションでDIを実現したりするわけですが、AngularJSの場合は引数名を頼りにインジェクションを行うようになっています。また、minifyされる場合を考慮して、インジェクション対象を文字列配列で指定することもできます。この例で言うと、_$httpBackend_という引数に$httpBackendサービスが渡されます。なお、インジェクション対象を判断する際、引数名の前後のアンダースコア(_)は、取り除いたうえでマッチングされるようです。
こうして得た$httpBackendサービスに対して、以降のコントローラの処理で'phones/phones.json'というURLに対するGETが期待されることと、そのレスポンスを設定しています。JasmineのSpy機能のようなものですね。なお、このようなモック化は、angular-mocksが必要になりますので、Karmaの設定ファイルでは、angular-mocks.jsを取り込むようになっています。

files : [
  'app/bower_components/angular/angular.js',
  'app/bower_components/angular-route/angular-route.js',
  'app/bower_components/angular-resource/angular-resource.js',
  'app/bower_components/angular-animate/angular-animate.js',
  'app/bower_components/angular-mocks/angular-mocks.js',
  'app/js/**/*.js',
  'test/unit/**/*.js'
],

テスト対象のコントローラ実装は、以下のようになっています。

phonecatControllers.controller('PhoneListCtrl', ['$scope', 'Phone',
  function($scope, Phone) {
    $scope.phones = Phone.query();
    $scope.orderProp = 'age';
  }]);

Phoneという自前のサービスをインジェクションしてもらい、そのサービスのqueryメソッドを呼んでいます。このメソッドの先で$httpBackendサービスが介在しています。queryメソッドの戻り値は、Promiseになっていますので、リクエストは非同期に処理されることになります。一見するとサーバへのリクエストが完了しているように読めてしまいますが、実際には非同期に処理されますので注意が必要です。

it('should create "phones" model with 2 phones fetched from xhr', function() {
  expect(scope.phones).toEqualData([]);
  $httpBackend.flush();
  expect(scope.phones).toEqualData(
      [{name: 'Nexus S'}, {name: 'Motorola DROID'}]);
});

単体テストでは、この非同期処理を完了するきっかけを与えてやる必要があります。それが$httpBackendサービスのflushメソッドです。つまり、この呼び出しの前後でscope変数の内容を確認すると、flush前には空のままであり、flush後には事前に仕込んでおいたレスポンスが設定されていることを確認できます。もし、コントローラの処理に不備があって、'phones/phones.json'へのリクエストが行われないとテストは失敗しますし、flush後のscope変数の内容が想定した結果になっていなくてもテストは失敗するということです。


ちょっと慣れないと使いこなすのが難しいですが、こういったツールも積極的に取り入れて、スマートに開発できるようにしていきたいものです。

*1:一方、Protractorのほうは、AngularJSのアプリケーションでないと利用できないようです。以下、Protractorのドキュメントからの抜粋。

Protractor only works with Angular applications, so it waits for the `angular` variable to be present when it is loading a new page.