Jasmine × Karma × Gulp でつくるユニットテスト環境 入門 - AngularJS + TypeScript #3

はじめに

お恥ずかしながら、いままでユニットテストというものをまともに書いたことがありませんでした。いや、過去に何度かチャレンジしてみたものの、いまいちテストに対してリアリティというか使いドコロというのが見えないまま何度も挫折してきました。

ここしばらく AngularJS と戯れているうちに何がビューで何がビジネスロジックなのか、どういうコードがテスト ( しやすい ) 対象なのかがおぼろげながら見えてきた ( 気がする ) ので、そろそろ真面目にユニットテストを学んでいきたいと思います。

また、当エントリーでは TypeScript で記述したテストコードを実行する方法も併せて紹介します

Jasmine と Karma - JavaScript のテストツール

Jasmine - テスティングフレームワーク

jasmine

テスティングフレームワークとは、開発者が記述したテストコードを実行し、テスト対象となるアプリケーションが期待される状態にあるかどうかを検査するための仕組みを指します。Jasmine はそんなテスティングフレームワークの一つであり、AngularJS の標準テスティングフレームワークとされています。BDD ( Behavior Driven Development ) 形式を採用しており、RSpec とよく似た記法で記述するのが特徴です。

Jasmine のほかに mocha や QUnit などが著名なテスティングフレームワークとして挙げられますが1)なんとなくですが、ここ最近は mocha 推しの声をよく耳にします。、勿論 AngularJS のユニットテストとして Jasmine の代わりにそれらを使用することも可能です。

Karma - テストランナー

0286W-2y27

テストランナーとは、様々なブラウザでテストを実行し、その結果をまとめてレポートするためのツールを指します。Karma は Node.js 上で動作するテストランナーです。元々はTestacular という名称で Google が AngularJS の開発で使うために作ったものですが、2012 年にオープンソース化されたタイミングで業 ( カルマ ) を背負った名称に変わりました。

本記事では、Jasmine をベースにしてテストコード ( 実処理 ) を記述し、それらを Karma から実行してレポート ( テスト結果 ) を確認するという流れで進めていきたいと思います。

環境構築

前提条件

  • Mac OS X Yosemite
  • node.js インストール済み (v5.4.0 ~)
  • npm インストール済み (3.3.12 ~)

Jasmine と Karma をインストール

両方とも Node パッケージとして提供されているので、npm コマンドからインストールします。

まずは Karma からインストールするわけですが、 karma コマンドを使う関係上、 -g オプションを付けてインストールします。

$ npm install -g karma-cli

次に Karma から Jasmine を利用するプラグインをインストールします。

$ npm install --save-dev karma-jasmine

更に、テスト実行時に Web ブラウザを起動するためのプラグインをインストールします。今回は例として Chrome を使うので、karma-chrome-launcher をインストールします。

$ npm install --save-dev karma-chrome-launcher

Chrome 以外に Firefox や PhantomJS を使いたいという用途のために、同様のプラグインが用意されています。

Karma を初期化して設定ファイルを作成

Karma の設定ファイルを作成します。karma init コマンドを実行し、質問に答えていく形で設定していきます。

$ karma init
# (1) テスティングフレームワークを選択
Which testing framework do you want to use ?
Press tab to list possible options. Enter to move to the next question.
> jasmine
# (2) Require.js は使わないので、 NO
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
# (3) テストを実行するブラウザを選択
Do you want to capture any browsers automatically ?
Press tab to list possible options. Enter empty string to move to the next question.
> Chrome
>
# (4) テスト対象とテストコードとなるファイルを入力するが、とりあえず空欄のままで OK
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.
>
# (5) 4の質問で指定したファイルのうち、除外したいファイルを入力
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.
>
# (6) プロダクションコードやテストコードに変更が入ったタイミングでテストを再実行するかどうか
Do you want Karma to watch all the files and run the tests on change ?
Press tab to list possible options.
> yes

以上で karma.conf.jsという設定ファイルが生成されました。

module.exports = function(config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine'],
    files: [],
    exclude: [],
    preprocessors: {},
    reporters: ['progress'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['Chrome'],
    singleRun: false
  });
};

AngularJS と ngMock モジュールをインストール

最後に AngularJS 本体と ngMock モジュールをインストールします。ngMock とは、その名の通りユニットテスト用の様々なモックを提供するモジュールです。サーバーサイドがまだ未実装であるときなどにその代替として振る舞うことが出来ます。

npm 上でも提供されていますが、個人的な好みからこの二つは Bower を使ってインストールします。

Bower 自体が未インストールの場合は、npm install -g bowerコマンドを実行してインストールします。

まずはBowerプロジェクトを初期化します。

$ bower init
⋮
# 特に何も考えずに、ひらすら Enter キー連打で OK

BowerからAngularJS と ngMock モジュールをインストールします。

$ bower install --save angular angular-mocks

最終的にbower.json がこのようになっていればインストールは成功です。

{
  "name": "angularjs-test",
  "version": "0.0.0",
  "authors": [
    "yamada_naoki <yamada_naoki@r.recruit.co.jp>"
  ],
  "license": "MIT",
  "ignore": [
    "**/.*",
    "node_modules",
    "bower_components",
    "test",
    "tests"
  ],
  "dependencies": {
    "angular": "~1.4.0",
    "angular-mocks": "~1.4.0"
  }
}

これで自動テストを行うための下準備が整いました。これより実際にテストコードを記述して自動テストを体験してみるとします。

テストコードを書いて実行してみよう

AngularJS によるアプリケーションを対象としたユニットテストを何種類か紹介します。

フィルターのテスト

入力した文字列を大文字に変換するだけのシンプルなフィルターを作成してテストまでやってみます。

まずはテスト対象となるプロダクションコードを作成します。

var app = angular.module('app', []);
app.filter('upperFilter', function() {
   return function(input: string) {
       return angular.uppercase(input);
   };
});

入力された文字列を大文字に変換するというシンプルなモジュールを用意しました。

次にこれをテストするテストコードを作成します。

describe('upperFilterのテスト', function() {
    beforeEach(module('app'));
    var $filter;
    beforeEach(inject(function(_$filter_) {
        $filter = _$filter_;
    }));
    it('入力した文字列が大文字に変換される', function($filter) {
        var upperFilter = $filter('upperFilter');
        expect(upperFilter('hello, world!')).toEqual('HELLO, WORLD!');
    });
});

describe() はテストをグルーピングする関数です。第一引数にテスト対象となるオブジェクトの説明を記述します。上の例ではupperFilterというモジュールをテストするという旨が分かるように書いています2)日本語でおk。第二引数の関数内にテストケースを記述していきます。

beforeEach() でテスト実行前に済ませておきたい処理を実行します。一つ目は module() という ngMock の関数を使って app モジュールを読み込み、二つ目は inject() という関数を使って $filter サービスをインジェクトしています。

it()がテストケースを記述する関数で、第一引数にテストケースの説明を記述します3)日本語でおk。第二引数にテスト対象を実行するコードとその結果をチェックする処理をまとめた関数を記述します。テスト対象の実行は expect() 内で行い、その戻り値をチェックするという流れです。ここでは toEqual()という関数を使って結果をチェックしています。

karma.conf.js にテスト対象とテストコードとなるファイルを入力します。

// list of files / patterns to load in the browser
files: [
    'bower_components/angular/angular.js',
    'bower_components/angular-mocks/angular-mocks.js',
    'scripts/*.js',
    'spec/*.js'
],

テスト対象は AngularJS を利用したものなので、当然 AngularJS 本体と ngMock もテスト対象に含める必要があります。プロダクションコードとテストコードはワイルドカード (*) で指定することで、ファイルが増える度に追記しなくてもいいようにしておきました。

以下のコマンドを入力してテストを実行してみましょう。

$ karma start karma.conf.js

下記のようなログが出力されました。無事にテストはパスしているようですね。

INFO [karma]: Karma v0.12.36 server started at http://localhost:9876/
INFO [launcher]: Starting browser Chrome
INFO [Chrome 43.0.2357 (Mac OS X 10.10.3)]: Connected on socket rUTXe6c4zrKJR4PXacNo with id 39898568
Chrome 43.0.2357 (Mac OS X 10.10.3): Executed 1 of 1 SUCCESS (0.02 secs / 0.018 secs)

ためしにテストコードを書き換えてワザとテストを失敗させてみましょう。Karma は起動したままでテストコードを以下のように書き換えます。

describe('upperFilterのテスト', function() {
    beforeEach(module('app'));
    it('入力した文字列が大文字に変換される', inject(function($filter) {
        var upperFilter = $filter('upperFilter');
        expect(upperFilter('hello, world!')).toEqual('HelLO, WoRld!!!!!!!');
    }));
});

変更して保存するとテストが再開され、以下のようなログが出力されます。

INFO [watcher]: Changed file "/Users/yamadanaoki/Documents/sandbox/angularjs-test/spec/upperFilter_spec.js".
Chrome 43.0.2357 (Mac OS X 10.10.3) upperFilterのテスト 入力した文字列が大文字に変換される FAILED
    Expected 'HELLO, WORLD!' to equal 'HelLO, WoRld!!!!!!!'.
        at Object.<anonymous> (/Users/yamadanaoki/Documents/sandbox/angularjs-test/spec/upperFilter_spec.js:6:46)
        at Object.invoke (/Users/yamadanaoki/Documents/sandbox/angularjs-test/bower_components/angular/angular.js:4426:17)
        at Object.workFn (/Users/yamadanaoki/Documents/sandbox/angularjs-test/bower_components/angular-mocks/angular-mocks.js:2420:20)
Chrome 43.0.2357 (Mac OS X 10.10.3): Executed 1 of 1 (1 FAILED) ERROR (0.025 secs / 0.021 secs)

このように Karma が起動している間はファイルを更新する度にテストが実行され、失敗すればその結果がログに出力されるので、どこに問題があるのかすぐに分かるようになっています。

コントローラのテスト

ビューとロジックが適切に分離されていていれば、その分テストコードもわかりやすく書くことが出来ます。以下は AngularJS のコントローラのテストの例です。

まずはプロダクションコードを書いていきます。入力されたパスワードの強度をチェックするというものです。

var app = angular.module('app.controller', []);
app.controller('PasswordController', ['$scope', function($scope) {
   $scope.password = '';
   $scope.grade = function() {
       var size = $scope.password.length;
       if (size > 8) {
           $scope.strength = 'strong';
       } else if (size > 3) {
           $scope.strength = 'medium';
       } else {
          $scope.strength = 'weak';
       }
   };
}]);

$scopeオブジェクトに grade()という関数を定義しました。こいつの動作をチェックするテストコードを書いていきます。

describe('PasswordControllerのテスト', function() {
    beforeEach(module('app.controller'));
    var $controller;
    beforeEach(inject(function(_$controller_) {
        $controller = _$controller_;
    }));
    describe('$scope.grade', function() {
        it('入力する文字列が8文字より長いとパスワード強度が "strength" になる', function() {
            var $scope = {};
            var controller = $controller('PasswordController', {$scope: $scope});
            $scope.password = 'longerthaneightchars';
            $scope.grade();
            expect($scope.strength).toEqual('strong');
        });
        it('入力する文字列が3文字未満だと、パスワード強度が "weak" になる', function() {
            var $scope = {};
            var controller = $controller('PasswordController', {$scope: $scope});
            $scope.password = 'a';
            $scope.grade();
            expect($scope.strength).toEqual('weak');
        });
    });
});

$controller サービスをインジェクトしています。describe() は先に述べたようにテストをグルーピングする関数なので、ネストして書くことも出来ます。テスト対象となるオブジェクトの規模が大きくなると、コードの見通しを良くするために活用することが出来ます。

この例では二種類のパスワード強度をテストしていますが、重複しているコードがあります。これは以下のように書き換えることができます。

describe('PasswordControllerのテスト', function() {
    beforeEach(module('app.controller'));
    ⋮
    describe('$scope.grade', function() {
        var $scope,
            controller;
        beforeEach(function() {
            $scope = {};
            controller = $controller('PasswordController', {$scope: $scope});
        });
        it('入力する文字列が8文字より長いとパスワード強度が "strength" になる', function() {
            $scope.password = 'longerthaneightchars';
            $scope.grade();
            expect($scope.strength).toEqual('strong');
        });
        it('入力する文字列が3文字未満だと、パスワード強度が "weak" になる', function() {
            $scope.password = 'a';
            $scope.grade();
            expect($scope.strength).toEqual('weak');
        });
    })
})

beforeEach を使って $scope, controller の定義を外出ししました。これでテストコードは完成です。実行して以下のようにテストが2つともパスしていれば成功です。

INFO [karma]: Karma v0.12.36 server started at http://localhost:9876/
INFO [launcher]: Starting browser Chrome
INFO [Chrome 43.0.2357 (Mac OS X 10.10.3)]: Connected on socket Dk7yvpi1j8t36Lm_OlG- with id 88433638
Chrome 43.0.2357 (Mac OS X 10.10.3): Executed 2 of 2 SUCCESS (0.025 secs / 0.022 secs)

ディレクティブのテスト

データバインディングを含んだカスタムディレクティブの例を紹介します。

まずはプロダクションコードを書いていきます。

var app = angular.module('app.directive', []);
app.directive('aGreatEye', function() {
   return {
       restrict: 'E',
       replace: true,
       template: '<h1>lidless, wreathed in flame, {{1 + 1}} times</h1>'
   }
});

ものすごく単純ですが、一応ディレクティブのプロダクションコードが出来ました。これは <a-cgreat-eye></a-great-eye> というカスタムタグを HTML 側で使うと h1 タグに置き換えられ、その中に lidless, wreathed in flame, 2 times という文字列が書き出されます。 {{1 + 1}} はデータバインディングによって 2 と評価された結果として出力されます。

次にテストコードを書いていきます。

describe('great quotesのテスト', function() {
    beforeEach(module('app.directive'));
    var $compile,
        $rootScope;
    beforeEach(inject(function(_$compile_, _$rootScope_) {
        $compile = _$compile_;
        $rootScope = _$rootScope_;
    }));
    it('期待通りのコンポーネントに差し替わる', function() {
        var element = $compile('<a-great-eye></a-great-eye>')($rootScope);
        $rootScope.$digest();
        expect(element.html()).toContain('lidless, wreathed in flame, 2 times');
    });
});

順番に解説していきましょう。まず最初に $compile$rootScope をインジェクトします。次に、カスタムディレクティブはコンパイルされて というタグに置き換わり、$rootScope 内に配置されます。そして $digest ループを呼び出してデータバインディングを処理させます。これでようやくテスト結果の確認が出来るようになります。toContain() 関数を使って引数に渡した文字列が含まれているかをチェックします。

Karma を実行してテストをパスしているかどうかを確認してみましょう。

テストコードも TypeScript で書きたい

logo-typescript

プロダクションを TypeScript で記述するプロジェクトとなると、テストコードも TypeScript で書きたくなります。ここではそのための方法を紹介します。

必要な Node パッケージをインストール

karma-typescript-preprocessor というパッケージをインストールします。TypeScript で書かれたコードを一度 JavaScript にコンパイルして Karma に流し込むということで実現しているようです。生成されたファイルは用が済むとすぐに削除されるので、とくにゴミファイル等を意識しなくてよくなるのでとても便利です。

$ npm install --save-dev karma-typescript-preprocessor

もう一つ大事な設定として、typescript を ローカルにインストールします。「え?既にグローバルにインストールしてるのはダメなの?」と思いがちですが、karma から 直接グローバルの typescript コマンドを叩くことは出来ないので、ローカルにインストールする必要があります

$ npm install --save-dev typescript

karma の設定を修正

karma.conf.js に TypeScript 関連の設定を追記します。まずは preprocessors: {} を以下のように書き換えます。

// テストを実行する前に行う処理を定義
preprocessors: {
    // TypeScript で書かれたコードを JavaScript にコンパイルする
   'scripts/*.ts': ['typescript'],
   'spec/*.ts':    ['typescript']
},

次に karma-typescript-preprocessor に関する設定を追記します。

typescriptPreprocessor: {
    // options passed to the typescript compiler
    options: {
        sourceMap: false,     // (optional) Generates corresponding .map file.
        target: 'ES5',        // (optional) Specify ECMAScript target version: 'ES3' (default), or 'ES5'
        module: 'commonjs',   // (optional) Specify module code generation: 'commonjs' or 'amd'
        noImplicitAny: false, // (optional) Warn on expressions and declarations with an implied 'any' type.
        noResolve: false,     // (optional) Skip resolution and preprocessing.
        removeComments: true  // (optional) Do not emit comments to output.
    },
    // extra typing definitions to pass to the compiler (globs allowed)
    typings: [
        'typings/tsd.d.ts'
    ]
},

options: {} に渡すプロパティは、グローバルにある tsc に渡されるものです。AngularJS といった外部ライブラリを使用している場合は、typings: [] プロパティに型定義ファイルのパスを指定します。

テストを実行

Karma コマンドを叩いてテストを実行してみましょう。

$ karma start karma.conf.js
INFO [karma]: Karma v0.12.36 server started at http://localhost:9876/
INFO [launcher]: Starting browser Chrome
INFO [Chrome 43.0.2357 (Mac OS X 10.10.3)]: Connected on socket nOQcdY-6EZRXqYCvhJO6 with id 36409289
Chrome 43.0.2357 (Mac OS X 10.10.3): Executed 1 of 1 SUCCESS (0.019 secs / 0.015 secs)

問題なくテストを実行できました。TypeScript で書かれたコードを更新すると、これまで通り自動的にテストが再開されます。

INFO [watcher]: Changed file "/Users/yamadanaoki/Documents/sandbox/angularjs-test/spec/upperFilter_spec.ts".
Chrome 43.0.2357 (Mac OS X 10.10.3): Executed 1 of 1 SUCCESS (0.018 secs / 0.016 secs)

特に違和感はもなく正常に動作しているようです。

Karma を Gulp から実行する

開発環境を Gulp で構築している場合の為に、Karma を Gulp から実行する方法を紹介します。

Gulp 用の Node パッケージをインストールします。

$ nom install --save-dev gulp-karma

gulp タスクを定義します。

var gulp = require('gulp');
var karma = require('gulp-karma');
var testFiles = [
    'bower_components/angular/angular.js',
    'bower_components/angular-mocks/angular-mocks.js',
    'src/*.ts',
    'spec/*.ts'
];
gulp.task('karma', () => {
    return gulp.src(testFiles)
        .pipe(karma({
            configFile: 'karma.conf.js',
            action: 'run'
        }));
});

Karma 単体でテストしてた時は、テスト対象となるファイルを karma.conf.js 内に記述していました。しかし Gulp で動かすとなると、テスト対象となるファイル情報を Gulp に対して直接教えてあげる必要があります。そのため、gulp.src()に引数として渡す為にファイル情報を Gulpfile 内で定義しています。逆に言えば、Gulp から Karma を実行する場合は、karma.conf.js 内にファイル情報を記述する必要はありません。

では実行してみます。

$ gulp karma
[15:39:41] Requiring external module coffee-script/register
[15:39:42] Using gulpfile ~/Documents/sandbox/angularjs-test/gulpfile.coffee
[15:39:42] Starting 'karma'...
[15:39:42] Starting Karma server...
INFO [karma]: Karma v0.12.36 server started at http://localhost:9876/
INFO [launcher]: Starting browser Chrome
WARN [web-server]: 404: /favicon.ico
INFO [Chrome 43.0.2357 (Mac OS X 10.10.3)]: Connected on socket kY0Q9TzGi3P9_Qt2i2E_ with id 27612231
Chrome 43.0.2357 (Mac OS X 10.10.3): Executed 1 of 1 SUCCESS (0.025 secs / 0.016 secs)
$ 

タスクが走り、テストが実行されました。が、テスト完了と同時に終了してしまいました。Karma 単体の時と同じく監視状態にするには、actionwatch と設定します。

gulp.task 'karma', ->
    gulp.src testFiles
        .pipe karma
            configFile: 'karma.conf.js'
            action: 'watch'

これでタスクを実行すると、これまで通りファイルを変更する度にテストが再実行されるようになります。

$ gulp karma
[15:39:41] Requiring external module coffee-script/register
[15:39:42] Using gulpfile ~/Documents/sandbox/angularjs-test/gulpfile.coffee
[15:39:42] Starting 'karma'...
[15:39:42] Starting Karma server...
INFO [karma]: Karma v0.12.36 server started at http://localhost:9876/
INFO [launcher]: Starting browser Chrome
WARN [web-server]: 404: /favicon.ico
INFO [Chrome 43.0.2357 (Mac OS X 10.10.3)]: Connected on socket kY0Q9TzGi3P9_Qt2i2E_ with id 27612231
Chrome 43.0.2357 (Mac OS X 10.10.3): Executed 1 of 1 SUCCESS (0.025 secs / 0.016 secs)
# コードを修正
INFO [watcher]: Changed file "/Users/yamadanaoki/Documents/sandbox/angularjs-test/spec/upperFilter_spec.ts".
Chrome 43.0.2357 (Mac OS X 10.10.3): Executed 1 of 1 SUCCESS (0.019 secs / 0.017 secs)

締め

まだまだ初歩的なレベルですが、一応ユニットテストの入り口には立てたかなと思います。冒頭にも述べた通り、これまで何度もテストに挑戦しましたがその度に使いドコロのイメージが湧かなくて挫折してましたが、AngularJS というフレームワークの学習を経たことによってどうにか導入することが出来ました。MV* という仕組みに乗っかることでコードが役割ごとに分離され、見通しが良くなったことが要因かと思いますが、僕としては、まずはフレームワークを学び、その流れに乗っかることができてからユニットテストに手を出すのが学習方法として良いのではないかと思います。

脚注

脚注
1 なんとなくですが、ここ最近は mocha 推しの声をよく耳にします。
2, 3 日本語でおk